| name | typography-system |
| description | The shared low-level TYPESETTING foundation every output surface inherits — type scale, vertical rhythm, measure (line length), grid, font stacks, CJK/中文 correctness, PLUS the v2 升维 adaptive layer: variable fonts, fluid responsive type/space, dark/light adaptive color, perceptual accessibility (APCA/WCAG2.2), and motion typography. Use BEFORE producing ANY typeset artifact: HTML reports, PPT/slide decks, documents (md→HTML/PDF), web pages, app screens. Trigger: 排版 / typeset / typography / 字体 / 行距 / 字号 / 网格 / grid / line-height / measure / modular scale / 中文排版 / CJK / 可变字体 / variable font / 响应式 / fluid / responsive / 暗色 / dark mode / 无障碍 / a11y / accessibility / 动效 / motion. Composes UNDER html-style-router (this is the base; genre/brand only overrides color+accent+brand-font). NOT for: choosing a brand/genre visual identity (that is html-style-router), inline HTML snippets, or React component logic. |
| combo-tags | ["html-rendering","daily-book-reading","slides","document-typeset"] |
Typography System — AI-Fleet Typesetting Foundation
The base typographic layer all surfaces share. Genre/brand styling (McKinsey,
Economist, brand sidecars) layers ON TOP of this; it never replaces the scale,
rhythm, measure, or CJK rules here. Distilled from 10 canonical works
(Müller-Brockmann, Lupton, Bringhurst, Ruder, Samara, Tschichold, Vignelli,
Craig-Scala, Tondreau, Butterick) + a CJK layer the Western canon omits.
When to Trigger
Before generating ANY artifact that sets type: HTML report, slide deck, document,
web page, app screen. Run this FIRST (alongside or before html-style-router):
the router picks the look; this skill supplies the typesetting engineering
underneath it.
How to Consume (by surface)
- HTML / web / HTML-slides / pandoc-HTML docs — link or inline
tokens/typography.css. You get correct defaults for free: Latin-first mixed
font stack, :lang(zh) auto-switching body leading to 1.75, bounded measure,
modular scale, font-synthesis:none, CJK text-autospace/line-break:strict/
word-break:keep-all. Then apply the genre/brand layer for color + accent +
brand font-family only. Wrap content in .html-frame (composes with the
router's --page-max layout guard).
Optional social-card/poster display face: link
assets/fonts/black-sugar-plum-candy.css and use
font-family: var(--font-stack-playful-cjk-display) or the utility
.display-playful-cjk. This is for playful CJK headlines only, not body copy.
Optional pixel display face: link assets/fonts/geist-pixel-google.css
(Google Fonts, remote graceful fallback) and use
font-family: var(--font-stack-pixel-display) or .display-pixel. This is
for short Latin tech covers, countdowns, product moments, and neon/pixel
single-surface artifacts only. For Next.js projects that need the five
Vercel variants, use npm i geist plus geist/font/pixel; the exports are
GeistPixelSquare, GeistPixelGrid, GeistPixelCircle,
GeistPixelTriangle, and GeistPixelLine.
- PPT / slides — read
SURFACE_ADAPTERS.md#ppt: fixed 1280×720, scale ratio
1.333, ≥22px Han body floor, 64px safe inset, ≤4 sizes/slide. Inline all assets
(self-contained deck rule).
- Documents (pandoc → HTML/PDF/EPUB) —
SURFACE_ADAPTERS.md#doc: single text
column at measure, 11pt print body, Songti serif for Chinese long-form,
orphans/widows control, indent OR space (not both).
- App (iOS/SwiftUI) —
SURFACE_ADAPTERS.md#app: drive sizes from Dynamic Type
text styles mapped to the 1.25 scale (never .font(.system(size:N)) literals on
Han Text), CJK .lineSpacing ≈ 1.6–1.8×, PingFang for Han / SF for Latin.
GUIDANCE-ONLY (no CSS validator runs against SwiftUI).
v2 升维 — the adaptive elevation layer (read when the task needs it)
v1 is the static foundation; v2 elevates it along 5 dimensions the static layer
lacked. All v2 tokens compose on top of v1 and degrade gracefully (v1 output
is the floor). Linking tokens/typography.css gives you v2 by default.
- variable-font — weight is a
wght axis position (--vf-wght-*), not a static
face; font-optical-sizing:auto; real CJK heavy via the axis (heals v1's
"ban faux-bold but ship no real bold" gap). font-synthesis:none is the guard.
- fluid — one systematic type+space scale (
--step--2…--step-6, --space-*)
paired across a 320→1280px viewport; container-aware measure (.cq, opt-in,
capped at the v1 66ch target). PPT and web share the 1280 ceiling.
- adaptive-color (highest-value) —
light-dark() neutrals + color-scheme
give automatic dark mode with re-derived (not inverted) dark operands that
hold contrast; forced-colors/high-contrast bindings; .force-light/.force-dark
to pin a subtree.
- perceptual-a11y — APCA Lc targets over a WCAG2.2 floor, always-visible
:focus-visible, never px-lock body, opt-in .reading-mode.
- motion — duration/easing tokens +
.reveal/scroll emphasis, all behind a
global prefers-reduced-motion guard (the non-negotiable red-line).
- i18n (laws only, no token group) —
:lang() locale quotes + logical
properties + <html lang>. Vertical/ruby/per-script were dropped by the
anti-bloat critic (this house ships 95% zh+en horizontal).
See LAWS.md#v2-升维-elevation-layer for the 22 shipped laws by dimension and
what the critic dropped.
Verify (enforcement — this is CODE, not advice)
After writing an HTML-family artifact, run the validator:
python3 skills/shared/typography-system/scripts/validate_typography.py <file.html> [--strict] [--json]
python3 skills/shared/typography-system/scripts/validate_typography.py <file.css> --css
It checks the mechanically-verifiable laws — v1 (8pt spacing ramp, CJK-font-first,
decorative fonts, ≤2 families, modular-scale size count, CJK break-all/leading/
quotes/fullwidth-digits, unbounded measure, gradient text) PLUS v2 (variable-weight
keyword mix, @font-face without font-display, px-locked body, suppressed focus
outline, animation without reduced-motion guard, missing <html lang>, physical
direction properties) PLUS genre-boundary (genre-frame-hardcoded-gutter,
genre-stranded-dark, stacked-table-no-label — added 2026-06-25 after the visual
swarm; see Gotchas 11-12). ERROR fails; --strict fails on WARN too. The repo also
runs it as a PostToolUse advisory on .html writes. Tests: tests/test_validate_typography.py (37 cases). (APCA Lc, dark-mode contrast, and
reading-order need resolved colors/DOM and stay token-level advisories, not fake
ERRORs — see LAWS.md#v2-enforcement.)
Files
LAWS.md — 25 v1 laws + 22 v2 升维 laws (by dimension) + 19 red-lines + critic corrections + what the anti-bloat critic dropped. The rationale layer.
tokens/typography.css — :root custom properties + base rules, v1 + v2 elevation layer. Link this.
tokens/typography.json — same values (12 groups, schema 2.0) + font policy allowlists + view definitions + enforcement scope + v2_elevation metadata. For non-CSS generators (PPT/app/doc) and the validator.
assets/fonts/BlackSugarPlumCandy-Bold.ttf + black-sugar-plum-candy.css — optional OFL-licensed playful CJK display face for social cards, covers, posters, and mobile-beautification previews.
assets/fonts/geist-pixel-google.css — optional OFL-licensed Geist Pixel Google Fonts helper for short Latin pixel display lanes; remote CDN by design, not a bundled body font.
SURFACE_ADAPTERS.md — per-surface bindings (html/ppt/doc/web/app).
scripts/{build_tokens,build_docs}.py — regenerate JSON/MD from the swarm captures (state/typography-swarm/synth.json v1 + synth2.json v2 + critic2.json ship_set).
scripts/validate_typography.py — the enforcement validator (v1 + v2 checks).
Gotchas
- This is the BASE, not a competing style. It runs UNDER
html-style-router.
Genre/brand layers override only color, accent, and the brand font-family — never
the scale, rhythm, measure, or CJK rules. If a genre wants 13px body or a 3rd
decorative font, the genre is wrong, not this foundation.
- Never put a CJK font first in a mixed stack.
-apple-system / system-ui /
PingFang at index 0 resolve to a CJK face, so Latin letters and digits render in
the CJK font (wrong width, wrong shape). Latin family FIRST, named CJK after,
generic last. Use --font-stack-mixed-sans. The validator errors on this.
- Never carry Latin 1.5 line-height onto Chinese. Han glyphs are denser; body
needs 1.6–1.8 (default 1.75). The CSS does this automatically via
:lang(zh) — but
if you hardcode line-height:1.5 on a zh block you defeat it.
- Never synthesize bold/italic on CJK. Faux-bold merges Han stroke counters into
black blobs; oblique doesn't exist in the Han tradition. Load a real weight axis;
font-synthesis:none is set globally.
- Body measure must be bounded. A full-bleed
<p> with no max-width/
max-inline-size is a failure — Latin ≤75ch, CJK ≤45 Han chars. The base rules cap
p/li/blockquote; don't strip it.
- Spacing only on the 8pt ramp (4,8,12,16,24,32,48,64,96,128).
13px/15px/
17px magic numbers are why generated layouts look "almost right but off."
- ≤2 typeface families + mono. A CJK+Latin superfamily counts as 1. >4 distinct
sizes in one view or >8 across the artifact breaks the modular scale.
- (v2) A variable
wght axis must not co-exist with a keyword font-weight.
font-variation-settings:'wght' 680 + font-weight:bold makes the keyword
defeat the axis — use a numeric weight that mirrors the axis. Validator errors.
- (v2) Dark mode operands are RE-DERIVED, never naive inversions. A light hex
flipped to dark usually fails the contrast law on
#141414 (the critic's
near-miss). The light-dark() pairs in the CSS carry brightened/lifted dark
operands — don't replace them with filter:invert or a mirrored hex.
- (v2) Motion is always behind
prefers-reduced-motion. Every .reveal/
animation must collapse to its end-state under reduced-motion, and may only
touch opacity/transform (compositor-only, no reflow). Validator warns on a
bare animation with no guard.
- (genre boundary) A genre/report must NOT re-declare the frame or the color
system. This is the #1 way a styled report breaks on a phone. The visual
swarm (2026-06-25) caught the merged typography report doing all three:
.html-frame hardcoded calc(100% - 96px) (→ 294px phone column, CJK clipped
at the edge) instead of calc(100% - var(--page-gutter-total)); genre tokens
were flat light-only hex (→ color-scheme:light dark active but the page can
never go dark); and meta content used the #9B9B9D faint footer tier (sub-AA)
instead of --ink-muted #6B6B6E. Validator gates the first two
(genre-frame-hardcoded-gutter, genre-stranded-dark); contrast stays a
token advisory (use --ink-muted for content, reserve --ink-faint for
footer/disabled).
- (table reflow) A wide table needs
data-label on every <td>. The
foundation owns the <560px labelled reflow (thead hidden, td::before
re-injects attr(data-label)); the generator's only job is to emit
data-label="<column header>" so column identity survives the mobile stack.
Opt a genuinely single-column table out with class="no-reflow". TWO
complementary validator checks guard the two ways this breaks: the foundation
provides the td::before{content:attr(data-label)} RULE, so the failure for a
foundation-composing artifact is almost never the missing rule —
it is missing the data-label ATTRIBUTES (the rule re-injects empty strings).
(a) stacked-table-no-label WARNs when a responsive rule hides thead with NO
re-injection RULE (CSS mechanism incomplete). (b) table-reflow-no-data-label
WARNs when a >=3-column table reflows but its <td> lack the data-label
ATTRIBUTE (HTML data incomplete) — the exact gap the 2026-06-26 infra-audit fell
through (passed static+render, lost 内容/理由 column identity on mobile).
- (measure is a default, NOT a wall) Never measure-cap a layout-card BOX.
The body-measure cap is on
:where(p, li, blockquote, dd) (specificity 0) on
purpose: measure bounds READING TEXT, never a box. An <li>/<p> used as a
LAYOUT card (background/border, in a grid/flex) must override the cap so it
fills its column — either set max-inline-size:none on it, or add it to a
<ul class="cards">, or use .full-bleed. The visual swarm (2026-06-25)
caught the exec-card list stranded at 38em on the LEFT of a full-width desktop
frame (732px cards, 345px dead cream to their right) precisely because the
foundation blindly capped every <li> box; the old ".full-bleed opt-out"
comment promised an escape hatch that was never shipped (CLAIM-VS-CODE) — it
now exists. Card-list pattern: display:grid + an explicit
grid-template-columns (1fr mobile → 1fr 1fr via @media(min-width:760px))
so the card fills the column at a good measure, never strands. Tests lock the
:where lowering + the real .full-bleed.
- (centering) A measure-capped
<p> in a CENTERED block needs margin-inline:auto
— text-align:center alone is NOT enough. Same root as #13 (the cap on
:where(p, …) is max-inline-size with margin-inline:0), different symptom: a
centered footer/hero/callout <p> is capped to ~30em and LEFT-ANCHORED inside its
centered frame, so text-align:center only centers the text WITHIN that left-stuck
narrow block — the whole thing reads shifted-left. Measured (2026-06-26 infra-audit
footer, viewport 2000px): the .foot-inner block centered at pageCenter (1000) but
each <p> sat at center 687 (−313px) — max-width:494px, margin-inline:0. Fix is
component-level: footer p{margin-inline:auto} re-centers the capped BLOCK (keeps
the cap). E20: text-align:center is necessary but not sufficient; a capped block
also needs auto inline margins. The block being centered (margin auto on the frame)
does NOT imply its capped children are — verify the <p> box, not just the wrapper.
- (playful display font) 黑糖话梅 is opt-in display infrastructure, not a
default body face. Use
--font-stack-playful-cjk-display for social cards,
covers, posters, and mobile-beautification previews. Keep long-form reports,
dashboards, legal/compliance docs, and UI body text on the canonical mixed
sans/serif stacks. Content-pipeline visual generators enable it automatically
for headline/display lanes; set AI_FLEET_VISUAL_CJK_DISPLAY_FONT=system or
AI_FLEET_DISABLE_BLACK_SUGAR_FONT=1 to disable.
- (pixel display font) Geist Pixel is opt-in Latin display infrastructure, not
a body face and not a CJK face. Use
--font-stack-pixel-display or
.display-pixel for short tech covers, countdowns, product moments, and
html-neon-pixel-style accent lanes. Keep CJK text on the foundation's mixed
stacks, and keep reports/dashboards/docs on canonical body typography. Google
Fonts gives the quick font-family: "Geist Pixel" path; npm geist/font/pixel
gives the five Vercel variants when a Next.js project needs variant control.
Your Name | your@email.com