Skip to contentWolf-Rayet

Decision records

ADR-0035 The shipped subset is a decision, and the fall-through is its boundary

Accepted2026-09-02Phase 4, reopened

#Context

ADR-0022 decided that the three faces would be self-hosted and subset to the character set the docs site's own build renders, and put a number on it: 116 codepoints. ADR-0023 superseded ADR-0022 on the resolution axis — three primitives, two semantic roles, resolved per theme — and in the course of doing so shipped the full upstream distributions instead. It recorded no rejected option for that, and no consequence naming the reversal. The bytes moved from a decided quantity to an undecided one without a record, which is the failure mode this register exists to prevent: not a wrong decision, an unrecorded one.

What shipped is 2849 codepoints of Inter, 951 of Signika Negative and 342 of each Atkinson weight — 574,416 bytes per app, duplicated across apps/docs/public/fonts and apps/storybook/public/fonts for 1,148,832 bytes of served font. Playbook §7 commits every visual decision to config a build enforces. A glyph is a visual decision, and 2849 of them were arriving because that is what the upstream file happened to contain.

Two further things ADR-0023 left unrecorded belong in the same record, because they are the same decision seen from two other sides. It rejected one family everywhere, a superfamily, and family-as-density, but never rejected the option nearest to its own conclusion: Atkinson Hyperlegible everywhere rather than in the field themes only. And its Consequences named the emission risk and the fixture instrument but not the asymmetry the field split itself creates, which is the one consequence the split has that the other decisions do not.

#Decision

The faces are subset, and the subset is declared in the token source. generator.config.json gains familySubset, twenty-two Unicode ranges naming 119 codepoints, computed from this repository's own content rather than chosen: every codepoint appearing across 228 tracked files — docs/**/*.md, the docs app's own source, the Storybook stories the tone records are extracted from, the fixture scenes, packages/react/lib's built contracts, and the package READMEs. The docs site renders every ADR at /governance/adr/, so the ADR corpus is the docs corpus and the declaration must cover it.

The four sources this record measures directly — the component tone records (25 real strings, 47 codepoints), the fixture scenes (111 files, 66), the docs site's own built content (150 pages, 116) and the character classes the content linter permits ([A-Za-z][A-Za-z'-]* in lib/words.js, \d and [-–—] in range-dash.js, 67) — union to 116. The declaration is widened to 119 by the three codepoints the repository's tracked content carried that the build did not yet render — U+2022 •, U+2076 ⁶, U+2264 ≤ — because a check that must run a Next build to know what to check is a check the tokens job cannot run. 119 is the set a build can recompute from the tree alone, and it was a strict superset of the 116 at the moment it was measured. It is not one any more, and by this record's own doing: the docs site renders every ADR, this record names all three codepoints in the sentence above, and the four sources now render 119 as well. That is not an error in the measurement — it is the reason the declaration is drawn from the tree rather than from a build. A set defined by what a build happens to render is a set that changes when you write about it.

Upstream is vendored once, under packages/tokens/assets/fonts/upstream/, and the files both apps serve are generated from it by tools/subset-faces.py. That is the relationship css/ already has with the config: a committed artifact is a claim about what its source produces. Subsetting uses pyftsubset's default layout-feature set rather than * — retaining every optional feature cost 111,100 bytes in Signika Negative against 25,540, and an unused stylistic set is not a decision this system made — and keeps hinting, which is 4,544 bytes in Atkinson Hyperlegible and belongs to the face the field themes carry precisely for legibility.

The rule, as enforced. tools/verify-output.js recomputes the needed set from the tracked content sources on every run and fails the build in three ways. A codepoint the repository's content renders that is outside familySubset fails, named. A codepoint that is needed, present in a face upstream, and not in that face's shipped set fails, named per face. And every served file is hashed against assets/fonts/manifest.json, so a hand-edited binary, a stale one, or a declaration changed without re-running the producer all fail identically — they are the same failure. All three were proved firing before this record was committed.

What happens when a string uses a codepoint outside the shipped subset has two answers, and which one applies turns on whether upstream could have supplied the glyph at all. If the face has it upstream, the build fails and the fix is to add the codepoint to familySubset and re-run the producer. If the face does not have it upstream, subsetting cannot invent it: the codepoint is recorded as that face's declared fall-through in the manifest, and css/family.css's ui-sans-serif, system-ui, sans-serif renders it. Four codepoints are in no face at all — U+2500 ─, U+2502 │, U+2514 └, U+251C ├, the box-drawing characters docs/PLAYBOOK.md uses. Signika Negative additionally lacks U+03A3 Σ, U+207B ⁻ and U+26A0 ⚠; Atkinson Hyperlegible additionally lacks those three plus U+2075 ⁵, U+2076 ⁶ and U+2192 →. Naming them is the point: a fall-through that is recorded is a boundary, and a fall-through nobody wrote down is a rendering defect waiting to be discovered by a reader.

The producer is maintainer-run, not CI, because subsetting needs a font toolchain this repository does not otherwise carry and CI does not have. The guarantee CI holds instead is the manifest and the hashes, which is why the hash check is not optional decoration.

#Rejected options

Keep the full upstream distributions. The strongest option in the set, and the one already shipping. It costs nothing to maintain, no toolchain, no producer, no gate; any string anyone ever writes renders in the chosen face if upstream has the glyph at all, and no future ADR can break the build by using a character. It lost on §7. A system whose claim is that the rendered result is derivable from its source cannot have 2849 glyphs arriving because that is what a file downloaded from upstream happened to contain. The 959,320 bytes it costs across both apps are the smaller half of the objection; the larger half is that "which glyphs can this system render" had no answer in the repository, and now it is twenty-two ranges in the config with a check behind them.

Subset to the 116 the build actually renders. Tighter by three codepoints, and derived from exactly the four sources this record set out to measure — the most defensible number on its own terms. It lost on where the check would have to live. Recomputing the rendered set means building the docs site, which the tokens job does not do and should not start doing to answer a question about fonts; the check would have had to move to the docs job, away from every other family check, or split across three jobs that each see one source. Three codepoints of slack buys one check in one place that reads only the tree.

Atkinson Hyperlegible everywhere, rather than in the field themes only. This is the option ADR-0023 came closest to and never wrote down, and it had the best argument of any rejected option here: one face, one license, one metric system, no family axis to resolve at all, and the face with the strongest legibility case in the set applied to every reader rather than only to the two themes that face the sun. It would have made the display/body split a pure size decision in all four themes instead of only two, which is simpler than what shipped. It lost on what the interior themes are for. Atkinson Hyperlegible is drawn to maximize disambiguation between confusable letterforms under duress — its I, l and 1 are pulled apart, its apertures forced open — and those choices cost the evenness of colour and rhythm that makes long reading comfortable in controlled light. Playbook §4 says build the hostile cases first and promote inward, not that the hostile case's answer is the only answer; promoting a face built for glare into a comfortable office screen spends legibility the interior reader does not need and takes reading comfort they do. It also loses the thing this system uses families for at all: with one face everywhere, the theme axis carries no family information, and the split that tells a reader "this screen is field, not interior" collapses into colour alone.

Drop hinting to save the last bytes. Free on macOS, where CoreText largely ignores it, and worth 4,544 bytes in Atkinson Hyperlegible alone — 42% of that face's subset. It lost on which face it was taking them from. Atkinson exists in this system for field-day and field-night, and hinting is what holds stems on the pixel grid at small sizes on the platforms that use it. Saving bytes by degrading rasterization on the legibility face for the hostile themes inverts the reason the face is in the roster.

#Consequences

The field split's asymmetry, named rather than solved. Atkinson Hyperlegible ships no variable file at its canonical distribution, so field-day and field-night carry a weight axis quantized to 400 and 700, while interior-light and interior-dark carry Signika Negative's continuous 300–700 and Inter's 100–900. Any future token that interpolates weight has two themes it cannot interpolate in. Alongside that, display and body resolve to the same face in the field themes, so the role distinction that carries two faces in the interior themes carries only size in the field ones — a heading is a bigger body there, not a different voice. Neither of these is a defect to be fixed by choosing differently; they follow from the upstream distribution and from §4's mandate, and they are recorded here so that the next record that wants a weight axis or a display voice in the field themes knows it is asking for something the family tier cannot currently give.

Identical Lc numbers describe two different stem widths. contrastLc(text8, bg8) in src/apca.js takes two 8-bit colours and reads nothing about the face; every floor in generator.config.json is a fixed minLc per role. So the sixteen text-contrast checks report the same numbers in a field theme and an interior theme whenever the colours match, while the glyphs carrying that contrast have different stem widths, x-heights and apertures. This is a boundary of the check, not a failure of it: APCA's own published method makes required contrast a function of size and weight, and this system fixes the floor per role instead. Recording it means the next record that wants face-aware floors knows the term is currently missing rather than currently zero.

A new codepoint in a new record fails the build. The docs site renders every ADR, so writing an ADR that reaches for a character outside the 119 breaks verify-output.js with that codepoint named. The fix is one range in generator.config.json and a re-run of tools/subset-faces.py, which needs Python and fontTools on the machine doing it. That is real friction on the documentation path, which is this project's busiest path, and it is the price of the guarantee: the alternative is a character that silently renders in a different face mid-sentence, which is exactly the class of undecided rendering this record was opened to end.

Served font bytes fall by 83.5%. 574,416 to 94,756 per app; 1,148,832 to 189,512 across both. The repository itself keeps the 574,416 bytes of upstream, once rather than twice, because a subset that cannot be reproduced offline from a tracked source is not a build output.

Emission is untouched, and this was checked rather than carried. No file under packages/budget/fixtures/ references css/family.css or any --wr-family-* token; the scenes pin the generated WR Metric Slab instrument and src/stability.js re-pins it !important. The family tier does not resolve inside a fixture, so no committed emission value has a term either the faces or their subsetting could move.