moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,235 @@
#!/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();