ADR-0022 Three type families, self-hosted, legibility governs the hostile themes
#Context
The type scale has shipped since Phase 2 (ADR-0012, generator.config.json's spatial.type) with a size for every step and a family for none. Every rendered surface — the docs site, Storybook, the SalienceCheck demo — falls back to one hardcoded placeholder stack, ui-monospace, SFMono-Regular, Menlo, monospace, declared four separate times in three files (apps/docs/app/site/site.module.css, apps/docs/app/SalienceCheck.tsx, apps/storybook/.storybook/preview.tsx, apps/storybook/stories/SemanticColors.stories.tsx) rather than once in the token pipeline. Commit d444019 names this state directly: r136.dev is "a placeholder... it makes no visual decisions."
That placeholder was not an oversight. docs/spike-budget-engine-findings.md §1a records the budget engine's fixture scenes bundling a *generated* instrument font — wr-metric-slab-*.woff2, 208 glyphs, every one an arithmetic rectangle — specifically so that Phase 0's measurement work would not have to make a typeface decision to proceed: "Bundling a *real* face would close that term and smuggle a typeface decision into a phase that forbids them. Generating one closes the term and decides nothing... Phase 1 replaces it with whatever the type scale actually selects." A prior audit of this repository (this session, before this record) confirmed the deferral had held completely: no ADR, no commit, no config anywhere in this repository names Signika Negative, Inter, or Atkinson Hyperlegible. The three families below were decided outside this repository — in the design conversation that produced this record's instructions — and this ADR is where that decision first becomes a committed fact here, not a retrieval of one.
Three forces make the decision a token-pipeline change rather than a stylesheet edit. First, playbook §4's theme matrix already establishes that field-day and field-night are hostile viewing conditions with their own governing constraints — maximum contrast under glare, scotopic legibility in the dark — and a family choice that ignores that split would be exactly the "retrofitting environments" §4 warns rots a token architecture. Second, §7 commits every visual decision to config that a build enforces rather than a convention a stylesheet might honor: "a raw family name in any component or docs stylesheet is a failure" is only checkable if a family token exists to check against. Third, ADR-0004's own emission formula measures rendered pixels, and a family carries x-height, stroke weight and glyph width — properties that move exactly the pixels the budget engine sums. Shipping a family is not free with respect to the one number the whole system is a bet on, which is why this record re-measures every fixture scene before calling the change done.
#Decision
Three family roles join the type scale, in generator.config.json's spatial.family — beside spatial.type, not inside it. Display is Signika Negative, for headings and subheadings. Body is Inter, for body copy. Legibility is Atkinson Hyperlegible, for outdoor and machine-facing screens.
field-day and field-night select the legibility role wherever a type token would otherwise select body. §4 already names those two themes the hostile cases and legibility the governing constraint there; this is that constraint reaching the family axis the same way every other theme-conditional rule in this pipeline reaches its axis — as a table the generator applies, spatial.family.themeOverride, not a theme name compared in code. interior-light and interior-dark carry an empty override and resolve display and body as declared.
Every consumer reads one of three custom properties — --wr-family-display, --wr-family-body, --wr-family-legibility — never a face name. --wr-family-body is the one whose *value* changes per theme (redefined under [data-wr-theme="field-day"] and [data-wr-theme="field-night"] to var(--wr-family-legibility)), so a consumer that always asks for "body" gets the theme-correct face without knowing a swap exists — the same discipline --wr-text-primary already uses to flip polarity across the theme matrix.
Faces are self-hosted under packages/tokens/assets/fonts/, subset to exactly the character set the docs site's own production build renders (116 codepoints, captured from apps/docs/.next/server/app/**/*.html), and never fetched from a third party at runtime. A handful of codepoints outside any of the three faces' Latin (plus Greek, for Inter) coverage — arrows, exponents, box-drawing — fall through to each role's declared fallback stack (ui-sans-serif, system-ui, sans-serif), which is what a fallback stack is for; packages/tokens/assets/fonts/manifest.json records exactly which characters those are and why. The metric-slab instrument font in the budget engine's fixtures is untouched by this decision and stays an instrument: §1a's argument for generating rather than bundling a real face there was never about which face to choose, and swapping it for one of these three now would smuggle exactly the typeface-in-a-measurement-phase problem the spike closed.
A raw family name in any component or docs stylesheet is now a checked failure (packages/tokens/tools/verify-output.js, FAMILY section), the same enforcement discipline every other tier in this pipeline already carries.
#Rejected options
One family everywhere. The simplest possible reading of "pick a typeface," and it would have closed this ADR in one line. It loses because a single face cannot serve both an interior heading and a sunlit machine-facing readout without compromising one of them: a display face cut for a comfortable office screen is exactly the subtle-stroke, high-contrast-within-the-glyph design that §4's field-day mandate — "no subtle luminance steps" — argues against at the letterform level, and a legibility face built for scotopic and glare conditions reads as needlessly plain set as a page's own headings. Three roles is the minimum that lets each condition get the face actually built for it.
A system font stack (-apple-system, "Segoe UI", Roboto, ...), free, zero-latency, and the choice every OS already optimizes for its own screen. It loses on the same ground ADR-0004 rejected a metric that depended on which fonts a machine has installed: the rendered pixels — and therefore the emission number ADR-0004 defines as deviation from substrate over the governed area — would differ by platform, and a system whose founding bet is "the number is stable enough to gate a merge" cannot spend that stability on a variable nobody chose. §1a made the identical argument for the fixture instrument font a year earlier; this ADR is that argument reaching the product typeface instead of the measurement one.
Keep the monospace placeholder. Free, already shipped, and consistent across every surface it appears on today. It loses because it answers a question this repository was never asked and was explicit about not answering: commit d444019 calls it a placeholder that "makes no visual decisions," and a placeholder chosen for the property of deciding nothing cannot also be the system's typography decision without contradicting the reason it was chosen.
#Consequences
The emission numbers move. A family change moves x-height, stroke weight and glyph width, and ADR-0004's formula sums perceptual deviation over exactly the pixels a glyph occupies. This record requires every fixture scene re-measured against its committed ceiling before it is considered done, and any scene that crosses its ceiling is reported rather than silently rebalanced — the same discipline ADR-0004 itself established for a formula change.
The fixtures keep the metric font. wr-metric-slab-*.woff2 stays exactly what §1a built it to be — a generated instrument bundled specifically so the measurement harness never depends on which real face is installed or chosen. This decision supplies the product's face; it does not touch the ruler.
Three roles, one override table, no code branch. spatial.family.themeOverride is the only place a theme is named in connection with the family axis. A fifth theme, or a change in which themes count as hostile, is a config edit here — the same shape as ADR-0007's density clamp and ADR-0013's stacking contexts, and the reason this reaches CSS as a per-theme custom-property redefinition rather than a conditional in spatial.js.
A checked failure surface grows by one. verify-output.js gains a FAMILY section: the three roles resolve, the override table is complete and self-consistent, and no component or docs stylesheet declares a literal family name. This is the enforcement §7 promises and could not previously deliver, because there was no family token to check a raw name against.