Contrast as a build gate, not a review step
A colour rule enforced by review erodes under a deadline. Enforced by a failing build it survives — and it catches a class of defect no amount of looking at the page will find.
Every team has the rule. Text must meet 4.5:1 against its background. Everybody agrees, nobody disputes it, and it still slips — because the rule is enforced by somebody noticing during review, and review is the first thing that gets thin when a date approaches.
A rule that depends on remembering it is a rule that erodes. The alternative is to make the rule a thing that fails the build.
Compute it, do not look it up
The first move is to stop trusting the numbers written in planning documents. A contrast ratio is a short, exactly specified calculation, so implement it and measure the actual tokens:
function linearise(channel: number): number {
const c = channel / 255;
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
export function relativeLuminance(hex: string): number {
const { r, g, b } = parseHex(hex);
return 0.2126 * linearise(r) + 0.7152 * linearise(g) + 0.0722 * linearise(b);
}
export function contrastRatio(a: string, b: string): number {
const la = relativeLuminance(a);
const lb = relativeLuminance(b);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}Twenty lines, no dependency. parseHex throws on anything that is not #rgb or #rrggbb, which matters more than it looks: a typo in a token becomes a loud failure instead of a silently wrong ratio.
Then enumerate the pairs, and check all of them
The reason contrast slips is rarely that someone chose a bad colour. It is that the number of pairs grows faster than anyone tracks.
This site has two base themes and seven accents, deliberately kept as independent axes so adding either is one edit rather than fourteen. The consequence is that there are fourteen appearances, and every required pair has to hold in all of them. Nobody is checking fourteen combinations by eye before a deadline.
So a script does, and prints the table:
=== base: Ink & Ember (ink-ember) ===
Ember #e8833a min=6.77:1 PASS (AA)
Cyan Signal #c3fffc min=16.65:1 PASS (AAA)
...
=== base: Slate & Volt (slate-volt) ===
Ember #e8833a min=6.69:1 PASS (AA)
...
tightest pair: slate-volt/ember Accent text on panel at 6.69:1 (min 4.5)The useful output is not the passes. It is that last line: the tightest pair in the entire matrix, named, with its margin. Everything else clears AAA by a wide margin; one combination sits at 6.69:1 against a 4.5 minimum. That is the pair that will break first when a colour is nudged, and it is now a number somebody can watch rather than a thing to be rediscovered.
Wire it into the command that has to pass
None of this helps if it is a script somebody remembers to run. It goes in the chain:
"check": "pnpm run typecheck && pnpm run lint && pnpm run format:check
&& pnpm run check:identity && pnpm run check:accents
&& pnpm run validate:content && pnpm run test:unit"and the same assertions exist as unit tests, so the failure arrives with a diagnosis attached rather than as a non-zero exit code from a reporting script. There is a second gate on prebuild, which means the browser suites — which run against a real production build — cannot start until content and identity checks have passed either.
What a token check cannot see
Here is the part worth the whole exercise, and it is the reason a token-level gate is necessary but not sufficient.
Checking colour pairs verifies the palette. It says nothing about what those colours become once the page composites them. Opacity, blend modes, a gradient underneath, a scrim, an overlay — all of these change the effective contrast of text whose declared tokens are perfectly fine.
A real example from this site. The wordmark's suffix had an idle animation that drifted its luminance, dropping to 0.5 opacity at the trough. The tokens involved were compliant. The composited result at 11px measured 2.61:1 — a serious failure that no palette check would ever report, because at no point is there a pair of hex values that fails.
That was caught by running axe against the rendered page, and the fix was to scope the animation to the one mark where the underlying colour could carry it rather than applying it globally.
So the arrangement is two gates doing different jobs:
- Token-level, in unit tests: every declared pair, in every theme and accent
- combination, computed exactly and fast enough to run on every commit.
- Rendered-page, via axe in the browser suite: what the composited result
- actually is, including the things opacity and blending do to it.
Neither substitutes for the other. The palette check cannot see compositing; the axe run cannot practically visit fourteen theme combinations on every route.
Generated, not hand-maintained
One more failure mode worth closing. If the accent scopes are written by hand in CSS and also declared in TypeScript, the two will drift, and the gate will faithfully check the copy that is no longer what ships.
So the CSS is generated from the registry, and a test asserts the generated output matches what is committed. A second test asserts that no accent scope declares a structural token — an accent that moved the background would make the two axes fight instead of compose, and that is a category of mistake, not a value to be checked.
The pattern generalises past colour: if a rule can be computed, compute it in a place that fails. The version of the rule that lives in a document is a rule about intentions. The version that exits non-zero is a rule about the code.
The same reasoning applies to writing the reduced-motion contract before any animation exists, and to the content schema that refuses to construct an unverified claim in the first place.