Token-hardening pass per round-9 redirect. No new visual decisions —
just plumbing the tokens correctly so the colour grammar locked in
Phase 4j has a system underneath instead of hand-tuned hex.
Scales.
NEW: src/design-system/v0.3-quietware-scales.css. 11-step tonal
scales (50, 100, ..., 900, 950) for five source hues and their five
complement sources. Saturation drops at extremes to avoid neon
highs and washed-out lows. Hue-stable lightness curve picked to
mimic Material 3 / IBM Carbon tonal scales.
Generated by scripts/generate-quietware-scales.py (committed) so
values are reproducible. To regenerate:
python3 scripts/generate-quietware-scales.py > scales.css
Wired into src/app.css directly before v0.3-quietware-tokens.css.
Component tokens consume scale references only.
All --button-{role}-{tier} and --progress-{state}-fill tokens now
read from var(--{hue}-{step}) instead of raw hex literals. Future
scale tuning happens in one place; component callers never change.
--button-record-bg: var(--red-600)
--button-primary-bg: var(--blue-700)
--button-success-bg: var(--green-800) /* darkened so white
text clears AA */
--button-caution-bg: var(--yellow-400)
--button-neutral-bg: var(--color-bg-elevated)
--button-disabled-bg: var(--color-bg-elevated)
--brand-accent: var(--orange-500)
Progress component tokens.
New family: --progress-{download,transcribing,success,caution,danger,
disk,disk-caution,disk-danger}-fill plus --progress-track. Each
mode tunes the shade step so the bar reads on its track:
Dark: blue-400 / green-500 / yellow-500 / red-500
Light: blue-700 / green-800 / yellow-600 / red-700
HC-light: blue-700 / green-800 / yellow-700 / red-700
HC-dark: blue-300 / green-400 / yellow-400 / red-400
LumotiaProgress.svelte gains tone="transcribing", "disk",
"disk-caution", "disk-danger" so callers map progress role to
token explicitly.
Border / wireline / focus-ring discipline.
Three distinct token families:
--button-primary-border structural component edge
--button-primary-wire complementary identity detail
--focus-ring-color keyboard interaction
Documented as separate concerns in the tokens file.
Contrast check script.
NEW: scripts/check-colour-contrast.mjs. Parses scales + tokens
CSS, builds per-scope token tables for dark / light / HC-light /
HC-dark, resolves var() chains and color-mix() expressions, then
verifies twelve load-bearing component pairs against WCAG 2.2
minima (4.5:1 text, 3:1 UI). Exit code 1 on failure.
Current state: 0 failures across 4 modes.
Two pairs reported as "warn" rather than "fail": caution progress
on cream + caution notice border on cream. The yellow source colour
cannot clear 3:1 on a near-white surface without losing its
identity. Role recognition is carried by left-bar + icon + label,
not by surface contrast.
Verified.
- node scripts/check-colour-contrast.mjs: 0 failures, 4 modes, 12
load-bearing pairs + 2 known-limitation warns.
- npm run check: 0 errors, 0 warnings across 5707 files.
Phase 5c (Dictation layout migration) is the natural next focused
sitting. This commit gives Phase 5c the systematic colour grammar to
build on, and the contrast check gates future palette regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
208 lines
8.1 KiB
JavaScript
208 lines
8.1 KiB
JavaScript
// 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<key,value> }.
|
|
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 <space>, 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);
|