438 lines
22 KiB
JavaScript
438 lines
22 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* ux-gate · contrast-runtime (composition-aware, Playwright)
|
||
* ────────────────────────────────────────────────────────────────────────────
|
||
* Pillar 4.2(b) + 4.3. Token-pair math (contrast-tokens.mjs) proves the tokens
|
||
* are AA in isolation; this proves it AFTER composition — opacity stacked up the
|
||
* DOM tree, and text painted over the wallpaper/gradient. Against a running dev
|
||
* server it walks every visible text node on each judged surface (after finite
|
||
* entrance animations settle), computes the EFFECTIVE foreground/background
|
||
* (ancestor-opacity composited; a real screenshot pixel sampled when an ancestor
|
||
* paints an image, blurs the backdrop, or the stack never reaches an opaque
|
||
* background — i.e. glass/scrim overlay subtrees CSS math cannot reconstruct),
|
||
* and reports:
|
||
*
|
||
* text nodes effective contrast < 4.5:1 (< 3:1 for WCAG-large text)
|
||
* focus indicators ring/outline contrast < 3:1 vs adjacent effective bg
|
||
* (tabs through up to 10 interactive elements per surface)
|
||
*
|
||
* Runs BOTH themes on: /home, /workspaces, /memory, /agents, /marketplace,
|
||
* /settings, and a workspace chat. Emits a JSON report + a human table and
|
||
* exits 1 on NEW failures vs `contrast-runtime-baseline.json` (a ratchet, seeded
|
||
* with `--update-baseline`). Seeds the onboarding-skipped power-tier entry the
|
||
* capture kit uses.
|
||
*
|
||
* Requires `playwright` (already a dev dep) and a reachable dev server
|
||
* (default http://127.0.0.1:3333, override WAGGLE_UX_BASE_URL). This gate is
|
||
* NOT wired into CI yet (that follows once it is proven — see README).
|
||
*
|
||
* npm run ux:contrast-runtime
|
||
* node scripts/ux-gates/contrast-runtime.mjs --surfaces=home,settings
|
||
* node scripts/ux-gates/contrast-runtime.mjs --update-baseline
|
||
*/
|
||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
import zlib from 'node:zlib';
|
||
import path from 'node:path';
|
||
|
||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||
const BASELINE = path.join(ROOT, 'scripts/ux-gates/contrast-runtime-baseline.json');
|
||
const REPORT = path.join(ROOT, 'scripts/ux-gates/.contrast-runtime-report.json');
|
||
const BASE = process.env.WAGGLE_UX_BASE_URL ?? 'http://127.0.0.1:3333';
|
||
const QUERY = 'skipOnboarding=true&skipBoot=true&tier=power&skipBriefing=true';
|
||
|
||
const TEXT_FLOOR = 4.5;
|
||
const LARGE_FLOOR = 3.0; // WCAG "large text": ≥24px, or ≥18.66px bold.
|
||
const FOCUS_FLOOR = 3.0; // WCAG 1.4.11 non-text contrast.
|
||
const MAX_NODES = 400; // per surface, to bound runtime.
|
||
|
||
const SURFACES = [
|
||
{ id: 'home', route: '/home' },
|
||
{ id: 'workspaces', route: '/workspaces' },
|
||
{ id: 'memory', route: '/memory' },
|
||
{ id: 'agents', route: '/agents' },
|
||
{ id: 'marketplace', route: '/marketplace' },
|
||
{ id: 'settings', route: '/settings' },
|
||
{ id: 'chat', route: '/chat' }, // resolved to /workspaces/:id/chat at runtime
|
||
];
|
||
|
||
// ── CLI ─────────────────────────────────────────────────────────────────────
|
||
const args = process.argv.slice(2);
|
||
const UPDATE = args.includes('--update-baseline');
|
||
const surfaceFilter = (args.find((a) => a.startsWith('--surfaces=')) ?? '').split('=')[1];
|
||
const wantSurfaces = surfaceFilter ? new Set(surfaceFilter.split(',').map((s) => s.trim())) : null;
|
||
|
||
// ── Colour math ─────────────────────────────────────────────────────────────
|
||
const srgbToLinear = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
|
||
const relLum = ({ r, g, b }) => 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
|
||
const composite = (fg, bg) => {
|
||
if (fg.a >= 1) return { r: fg.r, g: fg.g, b: fg.b, a: 1 };
|
||
const a = fg.a;
|
||
return { r: fg.r * a + bg.r * (1 - a), g: fg.g * a + bg.g * (1 - a), b: fg.b * a + bg.b * (1 - a), a: 1 };
|
||
};
|
||
const contrast = (fg, bg) => {
|
||
const l1 = relLum(composite(fg, bg)), l2 = relLum(bg);
|
||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||
};
|
||
|
||
/** Decode the single pixel of a 1×1 PNG (Playwright clip screenshot). For a
|
||
* 1×1 image every PNG filter is the identity (no left/up neighbour), so the
|
||
* inflated scanline is just [filterByte, ...pixelBytes]. */
|
||
function decodePixel(buf) {
|
||
let pos = 8, colorType = 6;
|
||
const idat = [];
|
||
while (pos + 8 <= buf.length) {
|
||
const len = buf.readUInt32BE(pos);
|
||
const type = buf.toString('ascii', pos + 4, pos + 8);
|
||
const data = buf.subarray(pos + 8, pos + 8 + len);
|
||
if (type === 'IHDR') colorType = data[9];
|
||
else if (type === 'IDAT') idat.push(data);
|
||
else if (type === 'IEND') break;
|
||
pos += 12 + len;
|
||
}
|
||
const raw = zlib.inflateSync(Buffer.concat(idat));
|
||
const px = raw.subarray(1);
|
||
if (colorType === 6) return { r: px[0], g: px[1], b: px[2], a: px[3] / 255 };
|
||
if (colorType === 2) return { r: px[0], g: px[1], b: px[2], a: 1 };
|
||
if (colorType === 0) return { r: px[0], g: px[0], b: px[0], a: 1 };
|
||
if (colorType === 4) return { r: px[0], g: px[0], b: px[0], a: px[1] / 255 };
|
||
return { r: px[0], g: px[1], b: px[2], a: 1 };
|
||
}
|
||
|
||
// ── In-page collectors (serialized by Playwright to the browser; they run in
|
||
// the page and use browser globals — document, getComputedStyle, innerHeight,
|
||
// scrollX/scrollY, NodeFilter — never Node scope) ──────────────────────────
|
||
function collectTextNodes(maxNodes) {
|
||
const parseColor = (str) => {
|
||
if (!str || str === 'transparent' || str === 'none') return { r: 0, g: 0, b: 0, a: 0 };
|
||
// Modern engines serialize computed colours from color-mix()/wide-gamut/
|
||
// color() as `color(srgb r g b / a)` (0–1 floats) rather than rgb()/rgba().
|
||
// The rgb() regex misses it, so such a foreground parsed to transparent-black
|
||
// → composite === bg → a fabricated 1.0:1 (the 'Fallback' rail is a
|
||
// color-mix()). Handle both notations.
|
||
const cm = str.match(/color\(srgb\s+([^)]+)\)/i);
|
||
if (cm) {
|
||
const q = cm[1].split(/[\s/]+/).map((s) => parseFloat(s)).filter((n) => !Number.isNaN(n));
|
||
return { r: q[0] * 255, g: q[1] * 255, b: q[2] * 255, a: q[3] === undefined ? 1 : q[3] };
|
||
}
|
||
const m = str.match(/rgba?\(([^)]+)\)/i);
|
||
if (!m) return { r: 0, g: 0, b: 0, a: 0 };
|
||
const p = m[1].split(/[,/]/).map((s) => parseFloat(s));
|
||
return { r: p[0], g: p[1], b: p[2], a: p[3] === undefined ? 1 : p[3] };
|
||
};
|
||
const composite = (fg, bg) => {
|
||
if (fg.a >= 1) return { r: fg.r, g: fg.g, b: fg.b, a: 1 };
|
||
const a = fg.a;
|
||
return { r: fg.r * a + bg.r * (1 - a), g: fg.g * a + bg.g * (1 - a), b: fg.b * a + bg.b * (1 - a), a: 1 };
|
||
};
|
||
const results = [];
|
||
const seen = new Set();
|
||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
||
let node;
|
||
while ((node = walker.nextNode()) && results.length < maxNodes) {
|
||
const txt = (node.nodeValue || '').trim();
|
||
if (txt.length < 2) continue;
|
||
const el = node.parentElement;
|
||
if (!el || seen.has(el)) continue;
|
||
seen.add(el);
|
||
const rect = el.getBoundingClientRect();
|
||
if (rect.width < 1 || rect.height < 1) continue;
|
||
if (rect.bottom < 0 || rect.top > innerHeight || rect.right < 0 || rect.left > innerWidth) continue;
|
||
const cs = getComputedStyle(el);
|
||
if (cs.visibility === 'hidden' || cs.display === 'none') continue;
|
||
let opacity = 1;
|
||
for (let a = el; a; a = a.parentElement) {
|
||
const o = parseFloat(getComputedStyle(a).opacity);
|
||
if (!Number.isNaN(o)) opacity *= o;
|
||
}
|
||
if (opacity < 0.05) continue;
|
||
const fg = parseColor(cs.color); fg.a *= opacity;
|
||
const fontPx = parseFloat(cs.fontSize) || 14;
|
||
const bold = (parseInt(cs.fontWeight, 10) || 400) >= 700;
|
||
// Effective bg: composite backgrounds up the tree. Defer to a real screenshot
|
||
// pixel when — and only when — CSS compositing can't be trusted AND the node
|
||
// is the topmost thing painted at its position (see occlusion below):
|
||
// • an ancestor paints an image (wallpaper/gradient), OR
|
||
// • an ancestor blurs the backdrop (glass — the effective bg is the blurred
|
||
// content behind it, which no colour math can reconstruct), OR
|
||
// • the walk never reaches an opaque background (a semi-transparent overlay
|
||
// subtree — compositing the stack onto assumed-white is wrong, and in dark
|
||
// theme wildly so).
|
||
let imageBg = false, backdrop = false, foundOpaque = false;
|
||
const layers = [];
|
||
for (let a = el; a; a = a.parentElement) {
|
||
const acs = getComputedStyle(a);
|
||
if (acs.backgroundImage && acs.backgroundImage !== 'none') imageBg = true;
|
||
if ((acs.backdropFilter && acs.backdropFilter !== 'none') ||
|
||
(acs.webkitBackdropFilter && acs.webkitBackdropFilter !== 'none')) backdrop = true;
|
||
const bgc = parseColor(acs.backgroundColor);
|
||
if (bgc.a > 0) layers.push(bgc);
|
||
if (bgc.a >= 1 && !(acs.backgroundImage && acs.backgroundImage !== 'none')) { foundOpaque = true; break; }
|
||
}
|
||
// Occlusion: is a higher overlay (a modal scrim, a toast) painted over this
|
||
// node? If so, a screenshot at its position samples the OVERLAY, not the
|
||
// node's own background — so we MUST trust the CSS composite (its real design
|
||
// bg) instead. This is what stops the content BEHIND the TrialExpiredModal
|
||
// scrim (the 'Fallback' row + the whole /home cluster — real CSS contrast
|
||
// 4.8–6.7:1, but a screenshot scores them against the black scrim → ~1.0:1)
|
||
// from being frozen as fabricated failures. `elementFromPoint` returns the
|
||
// topmost painted element; the node is occluded unless that element is itself,
|
||
// a descendant, or an ancestor of it.
|
||
const px = Math.round(rect.left + Math.min(rect.width / 2, 4));
|
||
const py = Math.round(rect.top + rect.height / 2);
|
||
const top = document.elementFromPoint(px, py);
|
||
let occluded = false;
|
||
if (top && top !== el) {
|
||
occluded = true;
|
||
for (let a = top; a; a = a.parentElement) { if (a === el) { occluded = false; break; } }
|
||
if (occluded) for (let a = el; a; a = a.parentElement) { if (a === top) { occluded = false; break; } }
|
||
}
|
||
const snippet = txt.slice(0, 60);
|
||
if (!occluded && (imageBg || backdrop || !foundOpaque)) {
|
||
// Sample the top-left leading (line-height puts blank space above the cap
|
||
// height) — likelier to be background than a glyph stroke.
|
||
results.push({
|
||
snippet, fg, fontPx, bold, needsSample: true,
|
||
sx: Math.round(rect.left + scrollX + 1), sy: Math.round(rect.top + scrollY + 1),
|
||
});
|
||
} else {
|
||
let acc = layers.length ? layers[layers.length - 1] : { r: 255, g: 255, b: 255, a: 1 };
|
||
for (let i = layers.length - 2; i >= 0; i--) acc = composite(layers[i], acc);
|
||
results.push({ snippet, fg, fontPx, bold, needsSample: false, bg: { r: acc.r, g: acc.g, b: acc.b, a: 1 } });
|
||
}
|
||
}
|
||
return results;
|
||
}
|
||
|
||
/** Read the ring of the CURRENTLY focused element (the driver presses a real
|
||
* Tab between calls — evaluate cannot synthesize a trusted Tab). Never mutates
|
||
* focus. Returns [] when focus is on <body> (no element tabbed to yet). */
|
||
function collectFocusRing() {
|
||
const parseColor = (str) => {
|
||
if (!str || str === 'transparent' || str === 'none') return null;
|
||
// color(srgb …) as well as rgb()/rgba() — see collectTextNodes.
|
||
const cm = str.match(/color\(srgb\s+([^)]+)\)/i);
|
||
if (cm) {
|
||
const q = cm[1].split(/[\s/]+/).map((s) => parseFloat(s)).filter((n) => !Number.isNaN(n));
|
||
const a = q[3] === undefined ? 1 : q[3];
|
||
return a === 0 ? null : { r: q[0] * 255, g: q[1] * 255, b: q[2] * 255, a };
|
||
}
|
||
const m = str.match(/rgba?\(([^)]+)\)/i);
|
||
if (!m) return null;
|
||
const p = m[1].split(/[,/]/).map((s) => parseFloat(s));
|
||
const a = p[3] === undefined ? 1 : p[3];
|
||
if (a === 0) return null;
|
||
return { r: p[0], g: p[1], b: p[2], a };
|
||
};
|
||
const bgOf = (el) => {
|
||
for (let a = el; a; a = a.parentElement) {
|
||
const acs = getComputedStyle(a);
|
||
if (acs.backgroundImage && acs.backgroundImage !== 'none') return null; // sample
|
||
const c = parseColor(acs.backgroundColor); // color(srgb)-aware
|
||
if (c && c.a >= 1) return { r: c.r, g: c.g, b: c.b, a: 1 };
|
||
}
|
||
return { r: 255, g: 255, b: 255, a: 1 };
|
||
};
|
||
const el = document.activeElement;
|
||
if (!el || el === document.body || el === document.documentElement) return [];
|
||
const cs = getComputedStyle(el);
|
||
let ring = null;
|
||
if (cs.outlineStyle !== 'none' && parseFloat(cs.outlineWidth) > 0) ring = parseColor(cs.outlineColor);
|
||
if (!ring && cs.boxShadow && cs.boxShadow !== 'none') ring = parseColor(cs.boxShadow);
|
||
if (!ring && cs.borderColor) ring = parseColor(cs.borderColor);
|
||
const label = (el.getAttribute('aria-label') || el.textContent || el.tagName).trim().slice(0, 40);
|
||
const parentBg = bgOf(el.parentElement || el);
|
||
const rect = el.getBoundingClientRect();
|
||
return [{
|
||
tag: el.tagName.toLowerCase(), label, ring,
|
||
bg: parentBg, needsSample: parentBg === null,
|
||
sx: Math.round(rect.left + scrollX - 1), sy: Math.round(rect.top + scrollY + rect.height / 2),
|
||
}];
|
||
}
|
||
|
||
// ── Driver ──────────────────────────────────────────────────────────────────
|
||
async function samplePixel(page, sx, sy) {
|
||
try {
|
||
const buf = await page.screenshot({ clip: { x: Math.max(0, sx), y: Math.max(0, sy), width: 1, height: 1 }, type: 'png' });
|
||
return decodePixel(buf);
|
||
} catch {
|
||
return { r: 127, g: 127, b: 127, a: 1 }; // neutral fallback — avoids a false clean pass.
|
||
}
|
||
}
|
||
|
||
function floorFor(node) {
|
||
const large = node.fontPx >= 24 || (node.bold && node.fontPx >= 18.66);
|
||
return large ? LARGE_FLOOR : TEXT_FLOOR;
|
||
}
|
||
|
||
/** Wait out finite entrance animations before measuring. framer-motion modals /
|
||
* toasts animate opacity via the Web Animations API; measuring mid-fade
|
||
* multiplies every foreground by the transient ancestor opacity AND skews the
|
||
* bg composite — that, not a real contrast defect, is what produced the
|
||
* TrialExpiredModal 1.1–2.1:1 cluster and the 1.0:1 'Fallback' rows (verified:
|
||
* at 700ms the modal sat at 0.79 opacity, at rest 1.0 → AA). Infinite ambient
|
||
* loops (honey-pulse, float) are skipped so they can't hang the gate, and the
|
||
* whole wait is hard-capped. */
|
||
async function settleAnimations(page, capMs = 2500) {
|
||
await page.evaluate(async (cap) => {
|
||
const deadline = performance.now() + cap;
|
||
const pending = () => (document.getAnimations ? document.getAnimations() : []).filter((a) => {
|
||
if (a.playState !== 'running' || !a.effect) return false;
|
||
const timing = a.effect.getComputedTiming ? a.effect.getComputedTiming() : {};
|
||
return timing.iterations !== Infinity; // ignore ambient/infinite loops
|
||
});
|
||
while (pending().length && performance.now() < deadline) {
|
||
await Promise.race([
|
||
Promise.allSettled(pending().map((a) => a.finished)),
|
||
new Promise((r) => setTimeout(r, 100)),
|
||
]);
|
||
}
|
||
}, capMs).catch(() => { /* animation API unavailable — fall through to fixed wait */ });
|
||
}
|
||
|
||
async function auditSurface(page, surface, theme) {
|
||
const failures = [];
|
||
// Text nodes.
|
||
const nodes = await page.evaluate(collectTextNodes, MAX_NODES).catch(() => []);
|
||
for (const n of nodes) {
|
||
const bg = n.needsSample ? await samplePixel(page, n.sx, n.sy) : n.bg;
|
||
const ratio = contrast(n.fg, bg);
|
||
const floor = floorFor(n);
|
||
if (ratio < floor) {
|
||
failures.push({ theme, surface: surface.id, kind: 'text', snippet: n.snippet, ratio: +ratio.toFixed(2), floor });
|
||
}
|
||
}
|
||
// Focus rings — a real Tab is pressed between reads (evaluate cannot
|
||
// synthesize a trusted Tab). A fresh page.goto starts focus on the document,
|
||
// so the first Tab lands on the first focusable element.
|
||
const seenLabels = new Set();
|
||
for (let i = 0; i < 10; i++) {
|
||
await page.keyboard.press('Tab');
|
||
const [ring] = await page.evaluate(collectFocusRing).catch(() => []);
|
||
if (!ring) continue;
|
||
const dedupe = `${ring.tag}:${ring.label}`;
|
||
if (seenLabels.has(dedupe)) continue;
|
||
seenLabels.add(dedupe);
|
||
if (!ring.ring) {
|
||
failures.push({ theme, surface: surface.id, kind: 'focus', snippet: `${ring.tag} "${ring.label}"`, ratio: 0, floor: FOCUS_FLOOR, note: 'no visible ring' });
|
||
continue;
|
||
}
|
||
const bg = ring.needsSample ? await samplePixel(page, ring.sx, ring.sy) : ring.bg;
|
||
const ratio = contrast(ring.ring, bg);
|
||
if (ratio < FOCUS_FLOOR) {
|
||
failures.push({ theme, surface: surface.id, kind: 'focus', snippet: `${ring.tag} "${ring.label}"`, ratio: +ratio.toFixed(2), floor: FOCUS_FLOOR });
|
||
}
|
||
}
|
||
return failures;
|
||
}
|
||
|
||
async function resolveChatRoute(page) {
|
||
try {
|
||
const res = await page.request.get(`${BASE}/api/workspaces`);
|
||
if (!res.ok()) return null;
|
||
const rows = await res.json();
|
||
const id = Array.isArray(rows) ? rows[0]?.id : null;
|
||
return id ? `/workspaces/${id}/chat` : null;
|
||
} catch { return null; }
|
||
}
|
||
|
||
async function main() {
|
||
// Reachability.
|
||
try {
|
||
const res = await fetch(`${BASE}/`, { signal: AbortSignal.timeout(4000) });
|
||
if (!res.ok && res.status >= 500) throw new Error(`status ${res.status}`);
|
||
} catch (e) {
|
||
console.error(`\n✗ contrast-runtime — dev server not reachable at ${BASE} (${e.message}).`);
|
||
console.error(' Start it (e.g. `npm run dev` or the playwright webServer) then re-run. Exit 2.\n');
|
||
process.exit(2);
|
||
}
|
||
|
||
let chromium;
|
||
try { ({ chromium } = await import('playwright')); }
|
||
catch {
|
||
console.error('\n✗ contrast-runtime — `playwright` is not installed. Exit 2.\n');
|
||
process.exit(2);
|
||
}
|
||
|
||
const surfaces = SURFACES.filter((s) => !wantSurfaces || wantSurfaces.has(s.id));
|
||
const browser = await chromium.launch();
|
||
const allFailures = [];
|
||
try {
|
||
for (const theme of ['dark', 'light']) {
|
||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||
const page = await context.newPage();
|
||
// Seed theme + onboarding-skip before first paint.
|
||
await page.addInitScript((t) => {
|
||
try { localStorage.setItem('waggle-theme', t); } catch { /* pre-nav */ }
|
||
}, theme);
|
||
for (const surface of surfaces) {
|
||
let route = surface.route;
|
||
if (surface.id === 'chat') { route = (await resolveChatRoute(page)) ?? '/workspaces'; }
|
||
const url = `${BASE}${route}${route.includes('?') ? '&' : '?'}${QUERY}`;
|
||
try {
|
||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
||
await page.evaluate((t) => {
|
||
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
|
||
else document.documentElement.removeAttribute('data-theme');
|
||
}, theme);
|
||
await page.waitForSelector('main, [role="navigation"], .waggle-sidebar', { timeout: 12000 }).catch(() => {});
|
||
await page.waitForTimeout(700);
|
||
await settleAnimations(page);
|
||
const failures = await auditSurface(page, surface, theme);
|
||
allFailures.push(...failures);
|
||
process.stdout.write(` ${theme}/${surface.id}: ${failures.length} finding(s)\n`);
|
||
} catch (e) {
|
||
process.stdout.write(` ${theme}/${surface.id}: SKIPPED (${e.message.split('\n')[0]})\n`);
|
||
}
|
||
}
|
||
await context.close();
|
||
}
|
||
} finally {
|
||
await browser.close();
|
||
}
|
||
|
||
// ── Baseline diff ───────────────────────────────────────────────────────────
|
||
const keyOf = (f) => `${f.theme}|${f.surface}|${f.kind}|${f.snippet}`;
|
||
writeFileSync(REPORT, JSON.stringify({ generatedAt: new Date().toISOString(), base: BASE, failures: allFailures }, null, 2) + '\n');
|
||
|
||
if (UPDATE) {
|
||
writeFileSync(BASELINE, JSON.stringify({
|
||
generatedAt: new Date().toISOString().slice(0, 10),
|
||
note: 'Frozen composition-aware contrast failures (seeded against a running dev server). The gate fails on NEW failures beyond this set.',
|
||
keys: [...new Set(allFailures.map(keyOf))].sort(),
|
||
}, null, 2) + '\n');
|
||
console.log(`\nux-gate · contrast-runtime — baseline written: ${new Set(allFailures.map(keyOf)).size} frozen failure key(s).`);
|
||
process.exit(0);
|
||
}
|
||
|
||
const baseline = existsSync(BASELINE) ? JSON.parse(readFileSync(BASELINE, 'utf8')) : { keys: [] };
|
||
const frozen = new Set(baseline.keys ?? []);
|
||
const newFailures = allFailures.filter((f) => !frozen.has(keyOf(f)));
|
||
|
||
// ── Report ──────────────────────────────────────────────────────────────────
|
||
const pad = (s, n) => String(s).padEnd(n);
|
||
console.log(`\nux-gate · contrast-runtime — ${allFailures.length} finding(s) (${frozen.size} frozen), ${newFailures.length} NEW\n`);
|
||
if (allFailures.length) {
|
||
console.log(`${pad('theme', 7)}${pad('surface', 12)}${pad('kind', 7)}${pad('ratio', 7)}${pad('floor', 7)}text`);
|
||
console.log('─'.repeat(72));
|
||
for (const f of allFailures) {
|
||
const isNew = !frozen.has(keyOf(f));
|
||
console.log(`${pad(f.theme, 7)}${pad(f.surface, 12)}${pad(f.kind, 7)}${pad(f.ratio, 7)}${pad(f.floor, 7)}${isNew ? '▲ ' : ' '}${f.snippet}`);
|
||
}
|
||
}
|
||
console.log(`\n full report → ${path.relative(ROOT, REPORT)}`);
|
||
|
||
if (!frozen.size && allFailures.length) {
|
||
console.log('\n ⓘ No baseline yet. Review the findings above, then seed the ratchet with');
|
||
console.log(' `node scripts/ux-gates/contrast-runtime.mjs --update-baseline`.');
|
||
}
|
||
|
||
if (newFailures.length > 0) {
|
||
console.error(`\n✗ contrast-runtime FAILED — ${newFailures.length} NEW composition-aware contrast failure(s).\n`);
|
||
process.exit(1);
|
||
}
|
||
console.log('\n✓ contrast-runtime PASSED — no new composition-aware contrast failures.\n');
|
||
}
|
||
|
||
main().catch((e) => { console.error('contrast-runtime crashed:', e); process.exit(2); });
|