/** * Resolve design tokens to hex per theme, and measure WCAG contrast between them. * * Test-only. `badge.test.tsx` invented this to catch five variants rendering at * 1.07:1 in dark mode, and the Phase 4b border work needed it again to establish * that cr-web-demo's `#38425e` measures 1.79:1 against its own surface — below the * 3:1 that WCAG 1.4.11 wants for a control boundary, and something axe does not * check. The patterns are ported from a dark-only source, so every color pairing * they inherit has to be measured in *both* themes rather than trusted. * * It reads the generated token CSS rather than a computed style because jsdom does * not resolve `var()` chains, and because the token layer is the thing under test: * a pairing is only safe if the tokens themselves are safe, independent of whatever * a story happens to render. * * Excluded from `tsconfig.json` alongside the test files it serves — it imports * `node:fs`, and this package deliberately has no `@types/node` so that server-only * APIs cannot type-check their way into a component. */ import fs from 'node:fs'; import path from 'node:path'; /** * One member. Dark is the only theme (CLAUDE.md rule 3), and this stays a union so that * re-adding a theme is a type error at every call site rather than a silently unmeasured one. */ export type ThemeName = 'dark'; const read = (file: string) => fs.readFileSync(path.join(process.cwd(), 'src/tokens', file), 'utf8'); /** Every `--token: value;` declaration in a stylesheet, last one winning. */ const declarations = (css: string): Record => Object.fromEntries([...css.matchAll(/^\s*(--[a-z0-9-]+):\s*([^;]+);/gm)].map((m) => [m[1], m[2].trim()])); const layers = { palette: declarations(read('palette.css')), theme: declarations(read('theme.css')), dark: declarations(read('dark.css')), }; /** Token names declared in the `@theme` block, i.e. those that generate a utility. */ export const themeTokens = new Set(Object.keys(layers.theme)); /** * Map a Tailwind color utility back to the token it reads. * * Returns `null` for anything that is not a color — notably `text-size-*`, which * shares the `text-` prefix with the ink utilities. Conflating the two is what made * Badge's contrast assertion vacuous the first time it was written. * * The trailing segment is optional, which matters far more than it looks. Some * namespaces have a bare member — `--color-highlight` above all — and requiring a * suffix made `text-highlight` resolve to `null`. An unresolved utility is dropped * from the ink set entirely, so mutating a safe ink to `#3acefa`, which measures * 1.84:1 on the light page, left every contrast assertion green while silently * measuring one color fewer. Three separate pattern ports hit this independently, * and each was caught only by an incidental assertion on how many inks were found. * * The `themeTokens` guard is what makes the looser regex safe: `text-text` and * `bg-surface` still return `null`, because no such token is declared. * * **`code` IS IN THE FAMILY LIST, and it was not.** The eight syntax colours are declared as * `--color-code-1` … `--color-code-8`, so `text-code-3` mapped to nothing and dropped out of `inks` * silently — which is precisely the failure this docblock describes two paragraphs up. It surfaced * when `ToolCall` reverted its function-name ink to Figma's `colors/code/3` and the count guard * reported zero inks where it wanted one. The guard is the only reason it was not a silent pass. * * **`border-` takes an optional side suffix**, so `border-l-border-error` maps to the same * token as `border-border-error`. Without this, a single-edge boundary was invisible: `ChatLog` * moved its event banner to Figma's 4px left rule and every one of its four variants dropped * out of the `borders` set, which is precisely the silent-drop failure this docblock describes * one paragraph up. It was caught only by the incidental `toHaveLength(1)` count guard — the * same way the other three instances were caught. */ export function tokenForUtility(cls: string): string | null { const bare = cls.includes(':') ? cls.slice(cls.lastIndexOf(':') + 1) : cls; const match = /^(?:bg|text|border(?:-(?:x|y|s|e|t|r|b|l))?|fill|stroke|ring|outline|decoration|caret|shadow)-((?:surface|text|border|icon|accent|bg|highlight|danger|white|alpha|elevation|code)(?:-[a-z0-9-]+)?)$/.exec( bare ); if (!match) return null; const token = `--color-${match[1]}`; return themeTokens.has(token) ? token : null; } /** * Resolve a token through its `var()` chain to a literal value, in one theme. * * Returns the literal as authored, which may be `rgba(...)` rather than a hex — * translucent tokens have no theme-independent contrast and callers must say so * explicitly rather than silently skipping them. */ export function resolveToken(theme: ThemeName, token: string): string | undefined { let value: string | undefined = layers[theme][token] ?? layers.theme[token]; for (let i = 0; i < 8 && value?.startsWith('var('); i += 1) { const inner = value.slice(4, -1).trim(); value = layers[theme][inner] ?? layers.theme[inner] ?? layers.palette[inner]; } return value; } /** True when a resolved value is an opaque hex, so a ratio can be computed from it. */ export const isOpaqueHex = (value: string | undefined): value is string => /^#[0-9a-f]{6}$/i.test(value ?? ''); const relativeLuminance = (hex: string) => { const channels = [0, 2, 4] .map((i) => parseInt(hex.slice(i + 1, i + 3), 16) / 255) .map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; }; /** WCAG 2.x contrast ratio between two opaque hex colors. */ export function contrastRatio(a: string, b: string): number { const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05); } /** * The color tokens a class string actually emits, split by role. * * Derived rather than named, because naming them is a check that cannot fail: a * contrast test written as `checkPairings([{ ink: '--color-text-accent-strong', … }])` * asserts a fact about the token layer and stays green after the component is changed * to use something else. Swapping `text-text-accent-strong` for `text-highlight` in * CollapsiblePanel — a 1.84:1 pairing in light mode — passed exactly that way. * * Pass the output of the component's own `cva` so the assertion is about the component. */ export interface ColorTokens { /** Foreground tokens: `text-*`, `fill-*`, `stroke-*`. */ inks: string[]; /** Background tokens: `bg-*`. */ surfaces: string[]; /** Boundary tokens: `border-*`, `outline-*`, `ring-*`. */ borders: string[]; /** * Tokens that only apply to a disabled control, whatever their role. * * Separated because WCAG 1.4.3 and 1.4.11 both exempt inactive controls, so * `--color-text-disabled` at 1.64:1 on the light page is correct rather than a * defect. Kept as its own bucket rather than dropped, so a caller has to say out * loud that it is excluding them — the alternative is a contrast check that * quietly stops covering whatever gets a `disabled:` prefix. */ disabledOnly: string[]; } export function colorTokensIn(...classStrings: string[]): ColorTokens { const result: ColorTokens = { inks: [], surfaces: [], borders: [], disabledOnly: [] }; for (const cls of classStrings.join(' ').split(/\s+/).filter(Boolean)) { const token = tokenForUtility(cls); if (!token) continue; const variants = cls.includes(':') ? cls.slice(0, cls.lastIndexOf(':')) : ''; const bare = cls.includes(':') ? cls.slice(cls.lastIndexOf(':') + 1) : cls; const bucket = /(^|:)(disabled|aria-disabled|data-disabled)\b/.test(variants) ? result.disabledOnly : /^(?:border|outline|ring)-/.test(bare) ? result.borders : bare.startsWith('bg-') ? result.surfaces : result.inks; if (!bucket.includes(token)) bucket.push(token); } return result; } /** * Assert a token pairing clears a threshold in both themes. * * Returns a list of human-readable failures rather than throwing, so a test can * assert on the whole set at once and the output names every pairing that failed * instead of only the first. * * `minimum` is the caller's decision because WCAG's thresholds differ by role: * 4.5 for body text, 3 for large text (>=18.66px bold or >=24px) and for the * non-text boundaries of 1.4.11, and nothing at all for purely decorative fills. */ export function checkPairings(pairings: { name: string; ink: string; surface: string; minimum: number }[]): { failures: string[]; unmeasurable: string[]; } { const failures: string[] = []; const unmeasurable: string[] = []; for (const { name, ink, surface, minimum } of pairings) { for (const theme of ['dark'] as const) { const inkValue = resolveToken(theme, ink); const surfaceValue = resolveToken(theme, surface); if (!isOpaqueHex(inkValue) || !isOpaqueHex(surfaceValue)) { unmeasurable.push(`${name} (${theme}): ${ink}=${inkValue} on ${surface}=${surfaceValue}`); continue; } const ratio = contrastRatio(inkValue, surfaceValue); if (ratio < minimum) { failures.push( `${name} (${theme}): ${inkValue} on ${surfaceValue} = ${ratio.toFixed(2)}:1, wanted ${minimum}:1` ); } } } return { failures, unmeasurable }; }