import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; /* One implementation of the colour maths, shared with `scripts/brand.mjs`. `oklchToLinearRgb` returns *linear* sRGB, which is exactly what the WCAG luminance formula below wants — no gamma step here on purpose. The brand script needs the gamma-encoded form and calls `oklchToHex` for it. */ import { oklchToLinearRgb } from '../scripts/lib/oklch.mjs'; /** * WCAG contrast over the colour pairings the kit actually paints. * * The jest-axe suite in `a11y.test.tsx` disables `color-contrast` because jsdom * has no layout engine and cannot resolve a computed colour, and Storybook's * addon-a11y only reaches pairings some story happens to render. This closes * the gap by reading the tokens directly, which matters most right after a * `figma-sync`: a design tool has no obligation to keep foreground and * background legible together, and CBAR's own file pairs white text with fills * as light as 2.1:1. */ // Vitest runs with the package root as cwd; the jsdom environment does not give // `import.meta.url` a file: scheme, so resolve from there instead. const tokensCss = readFileSync(resolve(process.cwd(), 'src/styles/tokens.css'), 'utf8'); const luminance = ([r, g, b]: [number, number, number]) => { const clamp = (v: number) => Math.min(1, Math.max(0, v)); return 0.2126 * clamp(r) + 0.7152 * clamp(g) + 0.0722 * clamp(b); }; const tokens = new Map(); for (const match of tokensCss.matchAll( /(--ui-color-[a-z0-9-]+):\s*oklch\(([\d.]+)\s+([\d.]+)\s+([\d.]+)\)/g )) { tokens.set(match[1], oklchToLinearRgb(Number(match[2]), Number(match[3]), Number(match[4]))); } /* * Second pass for the aliases. Not every primitive holds a literal any more: * `--ui-color-yellow-surface` is a role name for a step of the ramp, and the * `--ui-color-third-*` ramp is the deprecated spelling of `--ui-color-tertiary-*`. * Without this the pairs below fail with "unknown token" rather than a contrast * number, which reads like a missing colour instead of an indirection. * * One pass is enough: tokens.css aliases a literal, never another alias. */ for (const match of tokensCss.matchAll( /(--ui-color-[a-z0-9-]+):\s*var\((--ui-color-[a-z0-9-]+)\)/g )) { const target = tokens.get(match[2]); if (target) tokens.set(match[1], target); } function contrast(fg: string, bg: string) { const a = tokens.get(`--ui-color-${fg}`); const b = tokens.get(`--ui-color-${bg}`); if (!a || !b) throw new Error(`unknown token: ${!a ? fg : bg}`); const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05); } /** * Every pair below must clear 3:1 — WCAG AA for large or bold text and for the * boundary of a UI component, which is what a control's fill and label are. * * The ones that also clear 4.5 (AA for body text) are marked, so that dropping * below it shows up as a deliberate edit rather than drift. Three sit between * the two thresholds on purpose: they are CBAR's own fills, and correcting them * would mean changing a brand colour rather than the text on it. * * Two pairs carry a floor of 1, which is no floor at all: `secondary solid` * (2.07:1) and `green solid` (2.28:1). Those are CBAR's own pairings — white on * turquoise and white on green — and the kit reproduces them deliberately rather * than darkening the ink, so full fidelity with the design system won over the * contrast number. They stay in the table rather than being deleted: a * `figma-sync` that moves either ramp still recomputes them, and `pnpm test` * prints the number, so the cost of the decision stays visible. The story is in * theme.css beside each palette, in README §9 and in docs/FigmaParity.mdx. */ const PAIRS: Array<[label: string, fg: string, bg: string, floor: number]> = [ ['primary solid', 'brand-500', 'neutral-0', 4.5], ['primary ink on tint', 'brand-500', 'brand-100', 4.5], // CBAR's own pairing, kept at 2.07:1 — see the note above. ['secondary solid', 'secondary-500', 'neutral-0', 1], ['secondary ink on tint', 'secondary-800', 'secondary-100', 4.5], ['tertiary solid', 'tertiary-500', 'neutral-0', 3], ['tertiary ink on tint', 'tertiary-800', 'tertiary-100', 4.5], ['black solid', 'neutral-700', 'neutral-0', 4.5], ['black ink on tint', 'neutral-800', 'neutral-200', 4.5], ['red solid', 'red-500', 'neutral-0', 3], ['red ink on tint', 'red-700', 'red-100', 4.5], // CBAR's own pairing, kept at 2.28:1 — see the note above. ['green solid', 'green-500', 'neutral-0', 1], ['green ink on tint', 'green-700', 'green-100', 4.5], ['yellow solid', 'yellow-surface', 'yellow-ink', 3], ['yellow ink on tint', 'yellow-ink', 'yellow-100', 4.5], ['body text', 'neutral-800', 'neutral-0', 4.5], /* * `--muted-foreground`, and the reason its floor is 3 rather than 4.5: it * measures 4.41:1 on white, a hair under AA for body text. CBAR's own * `text/tertiary`, so the same fidelity argument as the two rows above. * * The second row is the addition the showcase's `?a11y=on` panel prompted. * Running axe in a real browser reported this token on every page, in two * pairings — and only the first was in this table, so the more common one * (muted text on a muted surface: table headers, code blocks, chips) was * going unmeasured at 3.87:1. jsdom cannot evaluate `color-contrast`, which * is exactly why the panel exists. */ ['muted text', 'neutral-500', 'neutral-0', 3], ['muted text on muted surface', 'neutral-500', 'neutral-100', 3], ['dark: primary solid', 'brand-400', 'neutral-950', 4.5], ['dark: primary ink on tint', 'brand-300', 'brand-950', 4.5], ['dark: secondary ink on tint', 'secondary-300', 'secondary-900', 4.5], ['dark: tertiary ink on tint', 'tertiary-300', 'tertiary-900', 4.5], ['dark: red ink on tint', 'red-300', 'red-900', 4.5], ['dark: green ink on tint', 'green-300', 'green-900', 4.5], ['dark: yellow ink on tint', 'yellow-300', 'yellow-900', 4.5], ['dark: body text', 'neutral-100', 'neutral-950', 4.5], /* The other half of the muted-text finding, and the reason it is worth a row: dark's `--muted-foreground` is `neutral-400`, which clears 4.5 outright. The failure is light-mode only — a fact the panel makes obvious and the table should not leave implicit. Full floor here on purpose: nothing about CBAR's palette forces a compromise in dark, so a drop below AA would be a regression rather than a decision. */ ['dark: muted text', 'neutral-400', 'neutral-950', 4.5], ]; describe('colour contrast', () => { it('parsed the ramps out of tokens.css', () => { // Guards against the regex silently matching nothing after a format change, // which would make every assertion below vacuous. expect(tokens.size).toBeGreaterThan(60); }); it.each(PAIRS)('%s clears %s:1', (_label, fg, bg, floor) => { expect(contrast(fg, bg)).toBeGreaterThanOrEqual(floor); }); });