Repository Public
1#!/usr/bin/env node2/**3 * WCAG AA (4.5:1) check of the design tokens actually used for text, in BOTH themes, read from4 * src/app/globals.css, plus the two terminal palettes from the terminal controller.5 * Exit code 1 on any failure. Usage: node dev/check-contrast.mjs6 */7import fs from 'node:fs';8import path from 'node:path';9import { fileURLToPath } from 'node:url';1011const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');12const css = fs.readFileSync(path.join(root, 'src/app/globals.css'), 'utf8');13const block = (re) => Object.fromEntries([...(css.match(re)?.[1] ?? '').matchAll(/--color-([\w-]+):\s*(#[0-9a-fA-F]{6,8})/g)].map((m) => [m[1], m[2]]));14const light = block(/@theme\s*\{([\s\S]*?)\n\}/);15const dark = { ...light, ...block(/\n\[data-theme='dark'\]\s*\{([\s\S]*?)\n\}/) };16// Codex instance: accent-family overrides layered on the same neutrals (html[data-agent='codex']).17const codexLight = block(/\nhtml\[data-agent='codex'\]\s*\{([\s\S]*?)\n\}/);18const codexDark = block(/\nhtml\[data-agent='codex'\]\[data-theme='dark'\]\s*\{([\s\S]*?)\n\}/);19for (const [name, b] of [['codex light', codexLight], ['codex dark', codexDark]]) if (!b.accent) throw new Error(`${name}: accent override not found in globals.css`);20const themes = [['light', light], ['dark', dark], ['codex light', { ...light, ...codexLight }], ['codex dark', { ...dark, ...codexLight, ...codexDark }]];2122const rgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));23const alpha = (h) => (h.length === 9 ? parseInt(h.slice(7, 9), 16) / 255 : 1);24const over = (fg, bg, a = alpha(fg)) => '#' + rgb(fg).map((c, i) => Math.round(c * a + rgb(bg)[i] * (1 - a)).toString(16).padStart(2, '0')).join('');25const lum = (h) => {26 const [r, g, b] = rgb(h).map((v) => v / 255).map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));27 return 0.2126 * r + 0.7152 * g + 0.0722 * b;28};29const ratio = (a, b) => {30 const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p);31 return (x + 0.05) / (y + 0.05);32};3334let failures = 0;35const check = (label, fg, bg, min = 4.5) => {36 const r = ratio(fg, bg);37 if (r < min) failures++;38 console.log(`${r >= min ? 'ok ' : 'FAIL'} ${r.toFixed(2).padStart(5)} ${label}`);39};40for (const [name, t] of themes) {41 console.log(`\n== ${name} theme`);42 for (const bg of ['bg', 'surface', 'surface-2', 'elevated'])43 for (const fg of ['text', 'text-2', 'muted', 'accent', 'success', 'warning', 'error', 'coral-ink']) check(`${fg} on ${bg}`, t[fg], t[bg]);44 check('accent-fg on accent (buttons)', t['accent-fg'], t.accent);45 // tinted chips: the text token on its own vivid colour at the opacity used by Badge, over a card46 for (const [fg, tint, a] of [['accent', 'accent', 0.12], ['success', 'success-vivid', 0.12], ['warning', 'warning-vivid', 0.15], ['error', 'error-vivid', 0.12], ['coral-ink', 'coral', 0.15]])47 check(`${fg} on ${tint} chip`, t[fg], over(t[tint].slice(0, 7), t.surface, a));48 check('hover overlay keeps text-2 readable', t['text-2'], over(t.hover, t.surface));49 check('bg on success ("Back online" banner)', t.bg, t.success);50 check('bg on error (swipe action)', t.bg, t.error);51 check('white on brand gradient start', '#ffffff', t['brand-1']);52 check('white on brand gradient middle', '#ffffff', t['brand-2'], 3); // 15px+ semibold labels only53 check('white on brand gradient at its visible end', '#ffffff', over(t['brand-3'], t['brand-2'], 42 / 77), 3); // stops 58% → 135%: the box ends 42/77 of the way54 check('brand-ink on white (pill on the phone header)', t['brand-ink'], '#ffffff');55 check('accent focus ring / active nav on bg (non-text, 3:1)', t.accent, t.bg, 3);56}5758const ctl = fs.readFileSync(path.join(root, 'src/components/terminal/controller.ts'), 'utf8');59for (const name of ['dark', 'light']) {60 const body = ctl.match(new RegExp(`\\n ${name}: \\{([\\s\\S]*?)\\n \\},`))?.[1] ?? '';61 const pal = Object.fromEntries([...body.matchAll(/(\w+): '(#[0-9A-Fa-f]{6})'/g)].map((m) => [m[1], m[2]]));62 console.log(`\n== terminal ${name}`);63 for (const k of ['foreground', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', 'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', 'brightMagenta', 'brightCyan', 'brightWhite'])64 check(`${k} on background`, pal[k], pal.background);65}66// Codex terminal accents (cursor on its background, as a non-text UI component: 3:1)67for (const name of ['dark', 'light']) {68 const body = ctl.match(new RegExp(`\\n codex: \\{[\\s\\S]*?\\n ${name}: \\{([^}]*)\\}`))?.[1] ?? '';69 const cursor = body.match(/cursor: '(#[0-9A-Fa-f]{6})'/)?.[1];70 const bg = ctl.match(new RegExp(`\\n ${name}: \\{\\s*background: '(#[0-9A-Fa-f]{6})'`))?.[1];71 if (!cursor || !bg) throw new Error(`codex terminal ${name}: cursor/background not found`);72 check(`codex terminal ${name}: cursor on background`, cursor, bg, 3);73}74console.log(failures ? `\n${failures} contrast failure(s)` : '\nall checked pairs meet their minimum');75process.exit(failures ? 1 : 0);76