// v0.3 Phase 4k — Lumotia quietware colour-contrast check. // // Parses the v0.3 quietware CSS files, builds per-mode token tables // (dark / light / HC), resolves scale-token references, and verifies // every known component-token pair against the WCAG 2.2 minima: // // Normal text: 4.5:1 // Large text / UI: 3:1 // // Exits 0 on full pass, 1 on any failure. Designed to run pre-commit // or in CI so the colour grammar stays correct as the palette evolves. // // Invocation: node scripts/check-colour-contrast.mjs import { readFileSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const SCALES_PATH = resolve(here, "../src/design-system/v0.3-quietware-scales.css"); const TOKENS_PATH = resolve(here, "../src/design-system/v0.3-quietware-tokens.css"); // Pairs to check. Each entry: [fg-token, bg-token, label, min]. // Border-on-its-own-fill pairs intentionally omitted — those borders // serve hover/active state cues, not text legibility, and inherently // share hue with the fill. The caution-on-cream notice border is // included as a "known limitation" pair (yellow on cream is the // structural yellow problem; role identity is carried by the // left-bar + icon + label combo, not border contrast alone). const PAIRS = [ ["--button-record-fg", "--button-record-bg", "Record button text on red", 4.5], ["--button-primary-fg", "--button-primary-bg", "Primary button text on blue", 4.5], ["--button-danger-fg", "--button-danger-bg", "Danger button text on red", 4.5], ["--button-success-fg", "--button-success-bg", "Success button text on green", 4.5], ["--button-caution-fg", "--button-caution-bg", "Caution button text on yellow", 4.5], ["--button-neutral-fg", "--button-neutral-bg", "Neutral button text", 4.5], ["--progress-download-fill", "--progress-track", "Download progress vs track", 3.0], ["--progress-success-fill", "--progress-track", "Success progress vs track", 3.0], ["--progress-disk-danger-fill", "--progress-track", "Disk-danger progress", 3.0], ["--notice-info-border", "--color-bg-card", "Info notice border on card", 3.0], ["--color-text", "--color-bg", "Body text on background", 4.5], ["--color-text", "--color-bg-card", "Body text on card surface", 4.5], ]; // Pairs that fail but are KNOWN structural limitations. Report only, // do not count toward exit code. const KNOWN_LIMITATIONS = [ ["--progress-caution-fill", "--progress-track", "Caution progress on cream — known yellow limit", 3.0], ["--notice-caution-border", "--color-bg-card", "Caution notice border on cream — known yellow limit", 3.0], ]; // Each scope: name + selectors it represents. const SCOPES = [ { name: "Quietware DARK", selectors: [':root[data-design="quietware"]'] }, { name: "Quietware LIGHT", selectors: [':root[data-design="quietware"]', ':root[data-design="quietware"][data-theme="light"]'] }, { name: "Quietware HC light", selectors: [ ':root[data-design="quietware"]', ':root[data-design="quietware"][data-contrast="high"]', ]}, { name: "Quietware HC dark", selectors: [ ':root[data-design="quietware"]', ':root[data-design="quietware"][data-contrast="high"]', ':root[data-design="quietware"][data-contrast="high"]:not([data-theme="light"])', ]}, ]; // --- parser --- function parseCSS(source) { // Strip comments and split blocks. Each block: { selector, decls: Map }. const stripped = source.replace(/\/\*[\s\S]*?\*\//g, ""); const blocks = []; const blockRe = /([^{}]+)\{([^{}]*)\}/g; let m; while ((m = blockRe.exec(stripped)) !== null) { const selector = m[1].trim(); const body = m[2]; const decls = new Map(); for (const decl of body.split(";")) { const idx = decl.indexOf(":"); if (idx < 0) continue; const key = decl.slice(0, idx).trim(); const value = decl.slice(idx + 1).trim(); if (key.startsWith("--")) decls.set(key, value); } blocks.push({ selector, decls }); } return blocks; } const allBlocks = [ ...parseCSS(readFileSync(SCALES_PATH, "utf8")), ...parseCSS(readFileSync(TOKENS_PATH, "utf8")), ]; // Build per-scope token tables. function tableFor(scopeSelectors) { const table = new Map(); for (const sel of scopeSelectors) { for (const block of allBlocks) { // Match selectors as comma-separated lists too. const sels = block.selector.split(",").map(s => s.trim()); if (sels.includes(sel)) { for (const [k, v] of block.decls) table.set(k, v); } } } return table; } // Resolve a CSS value to a hex, following var() references through the table. function resolveToken(value, table, depth = 0) { if (depth > 25) return null; value = value.trim(); if (/^#[0-9a-fA-F]{3,8}$/.test(value)) return value; // var(--name, fallback) const varMatch = value.match(/^var\((--[a-zA-Z0-9-_]+)(?:,\s*(.+))?\)$/); if (varMatch) { const name = varMatch[1]; const fallback = varMatch[2]; if (table.has(name)) return resolveToken(table.get(name), table, depth + 1); if (fallback) return resolveToken(fallback, table, depth + 1); return null; } // color-mix(...) — handle the simple `color-mix(in , X N%, Y)` form. const mixMatch = value.match(/^color-mix\(\s*in\s+\w+\s*,\s*(.+)\s+(\d+(?:\.\d+)?)%\s*,\s*(.+)\)$/); if (mixMatch) { const a = resolveToken(mixMatch[1], table, depth + 1); const pct = Number(mixMatch[2]) / 100; const b = resolveToken(mixMatch[3], table, depth + 1); if (!a) return null; if (mixMatch[3].trim() === "transparent") return a; // contrast against the visible part if (!b) return a; return mixHex(a, b, pct); } // rgb(...) — rare, ignore for now. return null; } function hexToRgb(hex) { hex = hex.replace("#", ""); if (hex.length === 3) hex = hex.split("").map(c => c + c).join(""); if (hex.length === 8) hex = hex.slice(0, 6); return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)]; } function rgbToHex([r, g, b]) { return "#" + [r, g, b].map(c => Math.max(0, Math.min(255, Math.round(c))).toString(16).padStart(2, "0").toUpperCase()).join(""); } function mixHex(aHex, bHex, ratio) { const a = hexToRgb(aHex); const b = hexToRgb(bHex); return rgbToHex(a.map((v, i) => v * ratio + b[i] * (1 - ratio))); } function srgbLin(c) { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); } function luminance([r, g, b]) { return 0.2126 * srgbLin(r) + 0.7152 * srgbLin(g) + 0.0722 * srgbLin(b); } function contrast(a, b) { const la = luminance(hexToRgb(a)); const lb = luminance(hexToRgb(b)); const [hi, lo] = la > lb ? [la, lb] : [lb, la]; return (hi + 0.05) / (lo + 0.05); } // --- run checks --- let totalFails = 0; const out = []; for (const scope of SCOPES) { const table = tableFor(scope.selectors); out.push(`\n=== ${scope.name} ===`); for (const [fgKey, bgKey, label, min] of PAIRS) { const fgHex = resolveToken(table.get(fgKey) ?? "", table); const bgHex = resolveToken(table.get(bgKey) ?? "", table); if (!fgHex || !bgHex) { out.push(` ?? ${label}: ${fgKey}=${fgHex ?? "unresolved"} / ${bgKey}=${bgHex ?? "unresolved"}`); continue; } const ratio = contrast(fgHex, bgHex); const ok = ratio >= min; const marker = ok ? " ok" : "FAIL"; out.push(` ${marker} ${label} fg=${fgHex} on bg=${bgHex} ${ratio.toFixed(2)}:1 (min ${min}:1)`); if (!ok) totalFails += 1; } // Known limitations — report only. for (const [fgKey, bgKey, label, min] of KNOWN_LIMITATIONS) { const fgHex = resolveToken(table.get(fgKey) ?? "", table); const bgHex = resolveToken(table.get(bgKey) ?? "", table); if (!fgHex || !bgHex) continue; const ratio = contrast(fgHex, bgHex); const ok = ratio >= min; out.push(` ${ok ? " ok" : "warn"} ${label} fg=${fgHex} on bg=${bgHex} ${ratio.toFixed(2)}:1 (target ${min}:1)`); } } console.log(out.join("\n")); console.log(`\nTotal failures: ${totalFails}`); process.exit(totalFails ? 1 : 0);