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.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\}/) };16const themes = [['light', light], ['dark', dark]];1718const rgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));19const alpha = (h) => (h.length === 9 ? parseInt(h.slice(7, 9), 16) / 255 : 1);20const 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('');21const lum = (h) => {22 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));23 return 0.2126 * r + 0.7152 * g + 0.0722 * b;24};25const ratio = (a, b) => {26 const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p);27 return (x + 0.05) / (y + 0.05);28};2930let failures = 0;31const check = (label, fg, bg, min = 4.5) => {32 const r = ratio(fg, bg);33 if (r < min) failures++;34 console.log(`${r >= min ? 'ok ' : 'FAIL'} ${r.toFixed(2).padStart(5)} ${label}`);35};36for (const [name, t] of themes) {37 console.log(`\n== ${name} theme`);38 for (const bg of ['bg', 'surface', 'surface-2', 'elevated'])39 for (const fg of ['text', 'text-2', 'muted', 'accent', 'success', 'warning', 'error', 'info-ink']) check(`${fg} on ${bg}`, t[fg], t[bg]);40 check('accent-fg on accent (buttons)', t['accent-fg'], t.accent);41 // tinted chips: the text token on its own vivid colour at the opacity used by Badge, over a card42 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], ['info-ink', 'info', 0.15]])43 check(`${fg} on ${tint} chip`, t[fg], over(t[tint].slice(0, 7), t.surface, a));44 check('hover overlay keeps text-2 readable', t['text-2'], over(t.hover, t.surface));45 for (const stop of ['brand-1', 'brand-2', 'brand-3']) check(`brand-fg on ${stop} (gradient CTA label)`, t['brand-fg'], t[stop]);46 check('info-fg on info-ink (pending badge)', t['info-fg'], t['info-ink']);47 check('brand-ink on surface', t['brand-ink'], t.surface);48 check('accent on accent/12 tint over surface-2 (active nav)', t.accent, over(t.accent, t['surface-2'], 0.12));49 check('term-text on term-bg (shell block)', t['term-text'], t['term-bg']);50 check('term-dim on term-bg (shell block)', t['term-dim'], t['term-bg']);51 check('success on diff-add over surface', t.success, over(t['diff-add'], t.surface));52 check('error on diff-del over surface', t.error, over(t['diff-del'], t.surface));53 check('text on diff-add over surface', t.text, over(t['diff-add'], t.surface));54 check('text on diff-del over surface', t.text, over(t['diff-del'], t.surface));55 check('white glyph on brand-3 (logo, non-text 3:1)', '#ffffff', t['brand-3'], 3);56 check('accent focus ring / active nav on bg (non-text, 3:1)', t.accent, t.bg, 3);57}5859console.log(failures ? `\n${failures} contrast failure(s)` : '\nall checked pairs meet their minimum');60process.exit(failures ? 1 : 0);61