#!/usr/bin/env node /** * ux-gate · contrast-tokens * ──────────────────────────────────────────────────────────────────────────── * Pillar 4.2 (token-pair math). Parses the app's CSS custom-property graph * (apps/web/src/index.css + waggle-theme.css), resolves every text and surface * token to a concrete sRGB colour for BOTH themes, and asserts the documented * WCAG contrast floors: * * --text / --text-2 / --text-muted / --text-tertiary ≥ 4.5:1 (AA body text) * --focus-ring / --line-affordance ≥ 3.0:1 (WCAG 1.4.11) * * over each allowed surface token (--bg, --bg-2, --surface, --surface-2, * --surface-3) in dark AND light. Exits 1 on any DEFINED token that fails its * floor. Tokens the spec expects but that are not yet defined (e.g. Lane T's * --text-tertiary/--focus-ring/--line-affordance land in a parallel lane) are * reported as PENDING — loud, but non-fatal — so this gate is green today and * automatically enforces them the moment they exist. * * `--text-dim` is INFORMATIONAL only: it is the intentional sub-AA "dim" tier * that Lane T's --text-tertiary supersedes; it is measured and printed but not * enforced (enforcing it would fail by design). * * No dependencies. Run: `npm run ux:contrast` or `node scripts/ux-gates/contrast-tokens.mjs`. */ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); const CSS_FILES = [ path.join(ROOT, 'apps/web/src/index.css'), path.join(ROOT, 'apps/web/src/waggle-theme.css'), ]; // ── Enforcement config ────────────────────────────────────────────────────── const SURFACES = ['--bg', '--bg-2', '--surface', '--surface-2', '--surface-3']; const TEXT_ENFORCED = ['--text', '--text-2', '--text-muted', '--text-tertiary']; const TEXT_INFO = ['--text-dim']; // measured, not enforced (dim tier by design) const AFFORDANCE_ENFORCED = ['--focus-ring', '--line-affordance']; const TEXT_FLOOR = 4.5; const AFFORDANCE_FLOOR = 3.0; // ── Colour math (WCAG 2.x relative luminance) ─────────────────────────────── function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); } function hslToRgb(h, s, l) { h = ((h % 360) + 360) % 360 / 360; s = clamp(s, 0, 100) / 100; l = clamp(l, 0, 100) / 100; const k = (n) => (n + h * 12) % 12; const a = s * Math.min(l, 1 - l); const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1)); return { r: Math.round(f(0) * 255), g: Math.round(f(8) * 255), b: Math.round(f(4) * 255), a: 1 }; } function parseHex(hex) { let h = hex.replace('#', '').trim(); if (h.length === 3) h = h.split('').map((c) => c + c).join(''); if (h.length === 4) h = h.split('').map((c) => c + c).join(''); const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16); const a = h.length >= 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1; return { r, g, b, a }; } function srgbToLinear(c) { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); } function relLuminance({ r, g, b }) { return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b); } /** Composite a (possibly translucent) foreground over an opaque background. */ function composite(fg, bg) { if (fg.a >= 1) return fg; const a = fg.a; return { r: Math.round(fg.r * a + bg.r * (1 - a)), g: Math.round(fg.g * a + bg.g * (1 - a)), b: Math.round(fg.b * a + bg.b * (1 - a)), a: 1, }; } function contrast(fg, bg) { const effFg = composite(fg, bg); const l1 = relLuminance(effFg), l2 = relLuminance(bg); return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); } // ── CSS custom-property extraction ────────────────────────────────────────── function stripComments(css) { return css.replace(/\/\*[\s\S]*?\*\//g, ''); } /** Brace-match the block that opens at `openIdx` (index of the `{`). */ function blockBody(css, openIdx) { let depth = 0; for (let i = openIdx; i < css.length; i++) { if (css[i] === '{') depth++; else if (css[i] === '}') { depth--; if (depth === 0) return css.slice(openIdx + 1, i); } } return ''; } /** Collect every `--name: value` declaration from all blocks whose selector * matches `selectorRe` (anchored so `:root` never captures `:root .child`). */ function collectDecls(css, selectorRe) { const out = {}; let m; const re = new RegExp(selectorRe.source, 'g'); while ((m = re.exec(css)) !== null) { const openIdx = css.indexOf('{', m.index); if (openIdx === -1) continue; const body = blockBody(css, openIdx); const declRe = /(--[\w-]+)\s*:\s*([^;]+);/g; let d; while ((d = declRe.exec(body)) !== null) out[d[1]] = d[2].trim(); re.lastIndex = openIdx + 1; } return out; } function buildMaps() { let dark = {}, light = {}; for (const file of CSS_FILES) { const css = stripComments(readFileSync(file, 'utf8')); Object.assign(dark, collectDecls(css, /:root\s*\{/)); Object.assign(light, collectDecls(css, /:root\[data-theme="light"\]\s*\{/)); } // light inherits every dark declaration then applies its overrides. return { dark, light: { ...dark, ...light } }; } // ── Value resolution ──────────────────────────────────────────────────────── /** Textually expand every `var(--x, fallback)` into its raw value. */ function expandVars(value, map, seen = new Set()) { let out = value; for (let guard = 0; guard < 50 && out.includes('var('); guard++) { out = out.replace(/var\(\s*(--[\w-]+)\s*(?:,\s*([^()]*))?\)/g, (_, name, fb) => { if (seen.has(name)) return fb ? fb.trim() : ''; if (map[name] !== undefined) { seen.add(name); return map[name]; } return fb ? fb.trim() : ''; }); } return out.trim(); } /** Parse `H S% L% [/ A]` (space- or comma-separated) into an {h,s,l,a}. */ function parseHslChannels(inner) { const [chan, alphaPart] = inner.split('/'); const parts = chan.trim().split(/[\s,]+/).filter(Boolean); if (parts.length < 3) return null; const h = parseFloat(parts[0]); const s = parseFloat(parts[1]); const l = parseFloat(parts[2]); if ([h, s, l].some((n) => Number.isNaN(n))) return null; const rgb = hslToRgb(h, s, l); if (alphaPart !== undefined) rgb.a = clamp(parseFloat(alphaPart), 0, 1); return rgb; } /** Resolve a token name to a concrete colour, or null if undefined/unparseable. */ function resolveColor(name, map) { if (map[name] === undefined) return null; const v = expandVars(map[name], map); if (!v) return null; if (v.startsWith('#')) return parseHex(v); const hslM = v.match(/hsla?\(([^)]*)\)/i); if (hslM) return parseHslChannels(hslM[1]); const rgbM = v.match(/rgba?\(([^)]*)\)/i); if (rgbM) { const p = rgbM[1].split(/[\s,/]+/).map(Number).filter((n) => !Number.isNaN(n)); if (p.length >= 3) return { r: p[0], g: p[1], b: p[2], a: p.length >= 4 ? p[3] : 1 }; } // Bare `H S% L%` channels (e.g. a token that stores raw HSL for hsl()). if (/%/.test(v)) { const c = parseHslChannels(v); if (c) return c; } return null; } // ── Run ───────────────────────────────────────────────────────────────────── function run() { const maps = buildMaps(); const rows = []; const pending = []; let failures = 0; const checkGroup = (tokens, floor, enforced) => { for (const token of tokens) { for (const theme of ['dark', 'light']) { const map = maps[theme]; const fg = resolveColor(token, map); if (!fg) { if (theme === 'dark' && enforced) pending.push(token); continue; } for (const surfaceName of SURFACES) { const bg = resolveColor(surfaceName, map); if (!bg) continue; const ratio = contrast(fg, bg); const pass = ratio >= floor; const status = !enforced ? 'info' : pass ? 'pass' : 'FAIL'; if (enforced && !pass) failures++; rows.push({ token, surface: surfaceName, theme, ratio, floor, status }); } } } }; checkGroup(TEXT_ENFORCED, TEXT_FLOOR, true); checkGroup(TEXT_INFO, TEXT_FLOOR, false); checkGroup(AFFORDANCE_ENFORCED, AFFORDANCE_FLOOR, true); // ── Report ──────────────────────────────────────────────────────────────── const pad = (s, n) => String(s).padEnd(n); const header = `${pad('token', 18)}${pad('surface', 13)}${pad('theme', 7)}${pad('ratio', 8)}${pad('floor', 7)}status`; console.log('\nux-gate · contrast-tokens — WCAG token-pair floors\n'); console.log(header); console.log('─'.repeat(header.length + 4)); let lastKey = ''; for (const r of rows) { const key = r.token + r.theme; if (lastKey && key !== lastKey) console.log(''); lastKey = key; const mark = r.status === 'FAIL' ? '✗ FAIL' : r.status === 'info' ? '· info' : '✓ pass'; console.log(`${pad(r.token, 18)}${pad(r.surface, 13)}${pad(r.theme, 7)}${pad(r.ratio.toFixed(2), 8)}${pad(r.floor.toFixed(1), 7)}${mark}`); } if (pending.length) { const uniq = [...new Set(pending)]; console.log(`\n⚠ PENDING (expected by spec, not yet defined — will enforce once present): ${uniq.join(', ')}`); console.log(' (Lane T owns --text-tertiary/--focus-ring/--line-affordance; this gate is a no-op for them until they land.)'); } if (failures > 0) { console.error(`\n✗ contrast-tokens FAILED — ${failures} token/surface pair(s) below floor.\n`); process.exit(1); } const enforcedRows = rows.filter((r) => r.status !== 'info').length; console.log(`\n✓ contrast-tokens PASSED — ${enforcedRows} enforced pair(s) meet their floor.${pending.length ? ` (${new Set(pending).size} token(s) pending.)` : ''}\n`); } run();