1 Script That Checks Design Token Contrast in Light and Dark
One script resolves every token pair in every theme and exits non-zero, so a contrast failure stops the build instead of shipping. Every design token contrast check on the first page of results is a human pasting two hex codes into a web form, which cannot see that the identical line of CSS passes in light and fails in dark.
>This is one gate. Claude Design Sync builds the token system it checks, the audit that blocks off-brand output, and the live app both of them protect.

Claude Design Sync: Ship the Real App
The Two-Way Claude Code System for Shipping Production Apps Without Drift
Hello builders,
One script resolves every token pair in every theme you ship and exits non-zero, which turns contrast from something you eyeball into something the build enforces. Every design token contrast check I can find on the first page of results is a human pasting two hex codes into a web form. That tool is fine and it doesn’t catch the failure that actually ships, because contrast is not a property of a colour. It’s a property of a pair of colours, resolved on screen, and design systems don’t think in pairs. They think in names.
Same names, different verdict
Walk it through with two tokens. There is --text-body and there is --surface-1, and one line of component CSS uses them:
color: var(--text-body);
background: var(--surface-1);
In light mode those resolve to a near-black on a near-white, which clears the bar comfortably. Then I flip the theme to dark. It does what themes do, and now the same two names resolve to a light grey on a dark grey.

Same component, same line of code, nothing edited. In light theme #1a1a1a on #ffffff measures 17.4 to 1 and passes. In dark theme #8a8a8a on #3a3a3a measures 3.3 to 1 and fails. A grey-on-grey combination that looked sophisticated in the mockup is, measured, too low-contrast to read.
Nothing already in the system can see this. A conventions file names tokens, not pairs. A token gate checks that an approved token was used, and both of these are approved, so it passes. Every instrument reasons about names, and contrast lives in values, in a specific theme, at render time.
The bar itself is not a matter of taste, which is the rare gift here:
“The visual presentation of text and images of text has a contrast ratio of at least 4.5:1”
Large text drops to 3:1, and the exception people get wrong is the incidental one, which has four clauses and not three. Text has no contrast requirement when it is “part of an inactive user interface component, that are pure decoration, that are not visible to anyone, or that are part of a picture that contains significant other visual content.” Drop that fourth clause and you’ll over-correct a hero image that was exempt. (Understanding SC 1.4.3, W3C)
The gate, per theme
Save this as scripts/contrast-gate.mjs. It reads your own resolved token values, so nothing of mine is baked into it:
import { readFileSync } from 'node:fs'
// your resolved tokens, one map per theme
const themes = JSON.parse(readFileSync(process.argv[2], 'utf8'))
// the foreground/background pairs your components actually put together
const USED_PAIRS = [['--text-body', '--surface-1']]
function contrast(fg, bg) {
const L = hex => {
const c = [0,2,4].map(i => {
const v = parseInt(hex.slice(1+i, 3+i), 16) / 255
return v <= 0.03928 ? v/12.92 : ((v+0.055)/1.055) ** 2.4
})
return 0.2126*c[0] + 0.7152*c[1] + 0.0722*c[2]
}
const a = L(fg), b = L(bg)
const [hi, lo] = a > b ? [a, b] : [b, a]
return (hi + 0.05) / (lo + 0.05)
}
let failed = 0
for (const [fg, bg] of USED_PAIRS) {
for (const [theme, map] of Object.entries(themes)) {
const ratio = contrast(map[fg], map[bg])
if (ratio < 4.5) {
console.error(`FAIL ${theme}: ${map[fg]} on ${map[bg]} = ${ratio.toFixed(1)} (need 4.5)`)
failed++
}
}
}
process.exit(failed ? 1 : 0)
Fill USED_PAIRS from the pairs your own components declare, and hand it a JSON file of your resolved values. Mine, for the pair above, is four lines:
{
"light": { "--text-body": "#1a1a1a", "--surface-1": "#ffffff" },
"dark": { "--text-body": "#8a8a8a", "--surface-1": "#3a3a3a" }
}
Producing that JSON is your build’s job rather than mine: whatever emits your themed CSS can emit the same values as data, and if it cannot, reading the two theme blocks in dist/styles.css by hand gets you the handful of pairs that matter on day one.
Run it and it prints exactly one line:
node scripts/contrast-gate.mjs tokens.resolved.json
FAIL dark: #8a8a8a on #3a3a3a = 3.3 (need 4.5)
Then wire it into CI as its own step, beside the token gate:
{ "scripts": { "gate:contrast": "node scripts/contrast-gate.mjs tokens.resolved.json" } }
The process.exit(failed ? 1 : 0) is the whole point, and it’s where most gates quietly die. Two of the four token-lint rules people reach for ship at warning severity by default and a third does nothing at all until you configure it. A warning doesn’t fail a build, and the flag that would make it fail is off by default. So you copy a config from the docs, your pipeline turns green, and nothing was checked. A warning nobody reads is a comment. Set the severity to error, or run at --max-warnings=0.
When it fails, the fix is a token edit you already know how to make. Either the foreground is too close to the background in that theme or the background is, so lighten --text-body’s dark value or darken --surface-1’s until the pair clears 4.5. Change the value in the failing theme’s map only. If both themes reference one shared value, we do not have a contrast problem we can fix cleanly, we have a theming structure that needs per-theme values first, and it is much better to learn that from a failing test than from a user.
A floor, not a certificate
Add the second checkable rule the same way. Targets for pointer input are “at least 24 by 24 CSS pixels,” and undersized ones pass when a 24-pixel circle centred on each does not intersect a neighbour’s. Know that spacing is one of five exceptions on that criterion, not the only escape, so read the list before you tell anyone their toolbar fails. (Understanding SC 2.5.8, W3C)
And ship this sentence next to the gate, because it is part of the deliverable. Automated tools, in the words of the people who write the guidelines, “can not determine accessibility, they can only assist in doing so.” The gate proves the resolved pairs clear 4.5 to 1. It proves nothing about focus order, or whether a label is correct, or whether the booking flow works for somebody who cannot see it. So we may tell a client “our automated checks pass.” We may not tell them “we are conformant,” and the distance between those two sentences is small in words and enormous in what it commits us to.
Now go build something this weekend!
John Cook