Forty generated pages, each of which looked right on its own. Put two side by side and the card edges disagreed; put four together and the muted text was three different greys. No page failed review, and no single file explained the drift — it was spread across forty files that had each been allowed to write a colour directly. Then someone opened a page nobody had checked in dark mode, and the body text came back near-black on near-black. A design token file had existed for weeks.
What we assumed a design token layer bought us
We assumed declaring the vocabulary was the work. The file was there, the names were sensible, and we treated adoption as a cultural matter — a convention generators would follow once reminded. Reminding does not produce a number.
Nobody was disobeying. A generator writes a shadow because the component needs one and inventing a value is cheaper than consulting a vocabulary it was never forced to open. They collect where a stylesheet linter does not look, because they are not stylesheet: an inline style attribute, an arbitrary bracket value inside a utility class, a CSS string assembled at runtime. Each produces a visual result without naming anything, which is why they accumulate quietly.
Adherence has a countable form
The useful part is not the advice that you should have design tokens; anyone building a multi-page product already agrees. It is that compliance is measurable on the shape of the output: you can count the places in generated markup that produce appearance without going through the named layer, and the result is an integer rather than a judgement. It should be zero.
Zero is a decision, not enthusiasm. Any threshold above it is a budget, and budgets get spent: the first allowed literal is argued for on merit and the second cites the first. A scan that matched no files also reports all zeros and is indistinguishable from a clean run, so an empty scan has to fail.
Counting also changes the remedy. Before declaring any names we census what the output already contains — distinct colour literals, spacing values, font sizes, shadows and radii, normalised so one colour written two ways collapses to one entry — or you cannot later show the number went down. That census is where the vocabulary comes from, rather than a template someone else’s product needed: a name earns its place when two components would otherwise disagree about the value. Ours settled at twenty-nine names across seven families, each filled colour shipping the ink that sits legibly on it so the pairing is never improvised at the call site.
Dark mode is where the drift becomes visible
The near-black-on-near-black page was not an isolated slip. A dark theme is generally correct on exactly the pages someone happened to open, and that is structural. A theme-scoped name with no dark declaration does not throw: it falls back to its light value and renders — legibly enough where it was reviewed, wrong everywhere else.
What is missing is a parity check: every theme-scoped name declared in both states, compared as a set difference rather than an impression, with both sets required to be non-empty, since two empty sets also differ by nothing. So you declare a name in the dark block even when its value matches the light one: repeating the value is what makes an absence a bug instead of a shrug.
Do not let the palette be its own witness
A contrast check that reads its colour values out of your own definition file will tell you the palette is internally consistent, which was never the question. What matters is whether the ink-on-ground pairings that actually shipped clear their threshold, and only the render knows which pairings those are. Both colours have to come off the rendered page, and the thresholds apply to computed size and weight, not to what the stylesheet asked for.
The only honest measurement is computed style read back from a rendered page, in both themes. It catches what no static pass can see — correct CSS, green build, and a name that resolves to nothing at runtime because it was declared on a scope the node is not inside. An unresolved reference falls silently back to the user-agent default and the compiler is satisfied. Build output is a claim about the rendered state, not the state itself.
What follows for anyone generating a lot of UI
A visual rule that cannot be counted is an opinion, not a gate. The test for any appearance convention is three questions: what integer does it produce, off which artifact, and what does the publish step do with it. If the answer to the third is that it lands in a summary, you have a metric, and metrics stop nothing from shipping.
The inversion worth running before trusting any of this: ask what a determined generator would do to produce a visual result without naming a token, then confirm your counter sees that. That question separates a violation counter from a lint config that happens to be green.
The runnable procedure — the census, the violation classes, the parity and read-back checks, and the guards that catch each of these failures — lives in the member library.
How we know
Grounded in: our own token-gate runs behind design-token-system v1.1, 2026-09-04 — a generated page set whose dark theme rendered near-black text on a near-black ground on pages nobody had opened. Verified: computed colour, background, padding, font size, border colour and radius read back from the live DOM in both themes and matched against the declared token values, with contrast ratios taken from the rendered colours rather than the definition file. The runnable procedure lives in the member library.
Leave a Reply