Files
waggle-os/scripts/ux-gates/contrast-runtime.mjs
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

438 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)` (01 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.86.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.12.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); });