// Site contrast check: every text token on every surface token in site/site.css, dark and light, must reach 4.5:1 // (the design's rule, docs/plans/site-ui-3.md 2.1). Reads the :root blocks of site.css; no browser. // node tools/ci/site-contrast-check.mjs exit 1 on any pair under 4.5 import { readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const css = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'site', 'site.css'), 'utf8'); function block(re) { const m = re.exec(css); if (!m) throw new Error('token block not found: ' + re); const out = {}; for (const t of m[1].matchAll(/--([a-z0-9-]+):\s*(#[0-9A-Fa-f]{6})/g)) out[t[1]] = t[2]; return out; } const dark = block(/:root\{([\s\S]*?)\n\}/), light = block(/:root\[data-theme="light"\]\{([\s\S]*?)\n\}/); const lum = h => { const c = [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255).map(v => v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; }; const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); }; const text = ['bone', 'ink-2', 'ash', 'ember-text', 'molten-text'], surf = ['obsidian', 'graphite', 'row']; let bad = 0, n = 0; for (const [name, t] of [['dark', dark], ['light', light]]) for (const a of text) for (const b of surf) { if (!t[a] || !t[b]) continue; n++; const r = ratio(t[a], t[b]); if (r < 4.5) { bad++; console.error(`contrast: ${name} --${a} ${t[a]} on --${b} ${t[b]} = ${r.toFixed(2)} (under 4.5)`); } } for (const [name, t] of [['dark', dark], ['light', light]]) { const r = ratio(t['ember-ink'], t['ember']); n++; if (r < 4.5) { bad++; console.error(`contrast: ${name} --ember-ink on --ember = ${r.toFixed(2)}`); } } console.log(`site contrast check: ${n} pairs, ${bad} under 4.5:1`); process.exit(bad ? 1 : 0);