Files
hyperframes/skills/hyperframes-creative/scripts/contrast-report.mjs
T
Miguel Ángel 1614dd3e5a fix(cli): sample real pixels behind hidden text for contrast-audit
## What

Fixes five reported false-positive/false-negative patterns in the WCAG contrast audit (`hyperframes validate --contrast`):

1. **SVG fill vs. text color** — foreground read from CSS `color` instead of SVG `fill`.
2. **Cross-component color bleed** — background estimate bleeds into a neighboring panel/layer.
3. **Backdrop-filter glass text** — background estimate misses the blur/tint and reads the raw backdrop.
4. **Partially-overlapping translucent decoration** — a decorative shape inside or partly touching the text's bbox goes undetected.
5. **Solid-fill pill/button** — investigated, did **not** reproduce; already handled correctly by the existing own-background ancestor walk. Not touched.

## Why

The audit estimated an element's background two ways:
- foreground: always `getComputedStyle(el).color` — wrong for SVG `<text>`/`<tspan>`, which is painted via `fill`, an independent CSS property.
- background: a 4px pixel ring sampled just **outside** the text's bounding box, with a fallback to an ancestor's opaque `background-color` for solid pills/buttons.

The ring is a proximity heuristic. It's wrong whenever what's immediately outside the text differs from what's actually behind it:
- text near the edge of its own panel, with a differently-colored sibling panel/layer just past the bbox — the ring samples the neighbor.
- a `backdrop-filter: blur()` glass panel sized only a couple pixels larger than the text — the ring exits the panel into the raw, unblurred, untinted backdrop.
- a translucent decoration that only partially overlaps the ring, or sits entirely **inside** the bbox — invisible to the ring regardless of size.

## How

**SVG fill (#1):** elements inside an `<svg>` (`el.ownerSVGElement`) now prefer the computed `fill` when it resolves to a solid `rgb()`/`rgba()` color, falling back to `color` for paint values that aren't a plain color (`none`, `context-fill`, gradient/pattern refs).

**Cross-comp bleed / glass blur / partial decoration (#2–#4):** replaced the ring-sampling + own-background-ancestor-walk heuristic with a two-phase capture:
1. `__contrastAuditPrepare()` walks the DOM, computes each candidate's foreground (unchanged logic from #1), and **hides that element's own text paint** (`color`/`fill` → `transparent`, layout-neutral — no reflow).
2. The caller takes **one** screenshot with the glyphs invisible (same number of screenshots as before — just moved after the hide instead of before it).
3. `__contrastAuditFinish(imgBase64, time, candidates)` restores the original paint immediately, then samples the **real composited pixels directly inside each element's own bbox** — no proximity heuristic needed, since these are the exact pixels that were behind the glyphs.

This is a real architectural change to `contrast-audit.browser.js`'s calling contract (single `__contrastAudit` → `__contrastAuditPrepare`/`__contrastAuditFinish`), with `validate.ts`'s `runContrastAudit` updated to match, including a try/finally restore-safety-net so a mid-loop screenshot/decode failure can't leave a later sample auditing a page with stale hidden text.

Mirrored the identical change in `skills/hyperframes-creative/scripts/contrast-report.mjs`, which duplicates the same DOM-walk/sampling logic (not just the WCAG math). There, the **visible** frame for the human-facing overlay image still comes from the producer's normal `captureFrameToBuffer` path (unchanged); only the **background-sampling** capture is a plain `session.page.screenshot()` taken after hiding text — deliberately bypassing `captureFrameToBuffer`, whose static-frame dedup cache knows nothing about the DOM mutation and would hand back a stale pre-mutation buffer.

**Solid-fill pill (#5):** reproduced a rounded pill/button with a busy page background outside it. The existing own-background ancestor walk already resolves the pill's declared `background-color` correctly regardless of the rounded corners — confirmed via repro, both before and after this change report the identical (correct) result. No fix needed; left untouched, and this case is covered by the new architecture too (would give the same right answer even without the ancestor-walk fallback).

Added `packages/cli/src/commands/contrast-sample.ts` (mirroring the existing `contrast-bg.ts`/`contrast-fg.ts` pattern) hosting the pure sample-rect/grid-point computation, unit tested — the browser-injected scripts can't import it directly, so it's kept in sync by hand, same convention as the rest of this file.

## Test plan

- [x] Unit tests: `contrast-fg.test.ts` (SVG fill resolution), `contrast-sample.test.ts` (sample-rect clamping/degenerate cases), plus the full `packages/cli` suite (1424 tests) passes, including an updated `layout-audit.browser.test.ts` case that called the old single-function `__contrastAudit` API directly.
- [x] Manual verification — standalone `puppeteer-core` harness against real `chrome-headless-shell`, one minimal HTML fixture per pattern, comparing the audit's reported ratio/verdict against a hand-constructed ground truth:
  - **SVG fill**: `fill:white` / no `color` on black bg → before: `fg=rgb(0,0,0)` ratio `1:1` (false FAIL); after: `fg=rgb(255,255,255)` ratio `21:1` (correct PASS).
  - **Cross-comp bleed**: text on a black sibling highlight box 2px larger than the text, white page bg outside it → before: `bg=rgb(255,255,255)` ratio `1.23:1` (false FAIL); after: `bg=rgb(0,0,0)` ratio `17.14:1` (correct PASS).
  - **Glass blur**: black text on an 18%-white-tinted `backdrop-filter: blur(14px)` panel over a yellow/blue gradient, panel only ~2px larger than the text → before: `bg=rgb(0,64,255)` (raw gradient color, blur/tint completely missed) ratio `3.18:1` (false FAIL); after: `bg=rgb(159,160,165)` (correct blurred/tinted blend) ratio `8.05:1` (correct PASS).
  - **Partial decoration**: text 92%-covered by a translucent white badge on a dark bg → before: `bg=rgb(16,16,16)` (ring never touches the badge, which sits entirely inside the bbox) ratio `17.45:1` (false PASS); after: `bg=rgb(171,171,171)` (correctly detects the badge) ratio `2.11:1` (correct FAIL).
  - **Solid pill sanity**: unaffected — `bg=rgb(10,10,10)` ratio `19.8:1` before and after.
- [x] End-to-end: ran the actual `hyperframes validate --contrast` CLI command (via `tsx src/cli.ts`) against a real scaffolded project containing all 4 patterns simultaneously — only the genuinely-failing case (the 92%-covered decoration) is reported (`1.09:1`, need `3:1`); the cross-comp-bleed, glass-blur, and solid-pill cases are correctly silent. A second vanilla scaffold with plain white-on-dark text produces zero false positives.
- [x] `oxlint`, `oxfmt --check`, and `tsc --noEmit` all pass on the changed files.
2026-07-09 18:01:48 -04:00

480 lines
19 KiB
JavaScript
Raw 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
// contrast-report.mjs — HyperFrames contrast audit
//
// Reads a composition, seeks to N sample timestamps, walks the DOM for text
// elements, measures the WCAG 2.1 contrast ratio between each element's
// declared foreground color and the ACTUAL pixels behind it, and emits:
//
// - contrast-report.json (machine-readable, one entry per text element × sample)
// - contrast-overlay.png (sprite grid; magenta=fail AA, yellow=pass AA only, green=AAA)
//
// Usage:
// node skills/hyperframes-creative/scripts/contrast-report.mjs <composition-dir> \
// [--samples N] [--out <dir>] [--width W] [--height H] [--fps N]
//
// Env:
// HYPERFRAMES_SKILL_PKG_VERSION — pin the @hyperframes/producer version used
// when bootstrapping (global skill installs cannot infer it; falls back to
// @latest with a warning otherwise).
//
// The composition directory must contain an index.html. Raw authoring HTML
// works — the producer's file server auto-injects the runtime at serve time.
// Exits 1 if any text element fails WCAG AA.
//
// Background sampling: each sample time is captured TWICE — once via the
// producer's normal (video-accurate) captureFrameToBuffer for the overlay
// image, and once via a plain page.screenshot() taken right after hiding
// every candidate element's own text paint (color/fill → transparent,
// layout-neutral). The second capture reveals the REAL composited pixels
// that were directly behind the glyphs, which this script then samples
// straight from each element's own bbox — no proximity heuristic needed.
// This is deliberately NOT routed through captureFrameToBuffer: that
// pipeline has a static-frame dedup cache keyed by frame index/time that
// knows nothing about our DOM mutation and would happily hand back a
// cached pre-mutation buffer. A direct page.screenshot() bypasses that
// entirely and is the same technique validated in
// packages/cli/src/commands/contrast-audit.browser.js.
//
// The previous approach sampled a 4px ring just OUTSIDE the bbox, which
// breaks down whenever what's immediately outside the text differs from
// what's actually behind it: a neighboring panel/component just past the
// text's edge, a backdrop-filter-blurred glass panel sized only a couple
// pixels larger than the text, or a translucent decoration that only
// partially overlaps the ring (or sits entirely inside the bbox, never
// touching the ring at all).
import { mkdir, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { hyperframesPackageSpec, importPackagesOrBootstrap } from "./package-loader.mjs";
// Use the producer's file server — it auto-injects the HyperFrames runtime
// and render-seek bridge, so raw authoring HTML works without a build step.
const packages = await importPackagesOrBootstrap(["@hyperframes/producer", "sharp"], {
npmPackages: [hyperframesPackageSpec("@hyperframes/producer"), "sharp@0.34.5"],
});
const sharp = packages.sharp.default;
const {
createFileServer,
createCaptureSession,
initializeSession,
closeCaptureSession,
captureFrameToBuffer,
getCompositionDuration,
} = packages["@hyperframes/producer"];
// ─── CLI ─────────────────────────────────────────────────────────────────────
const args = parseArgs(process.argv.slice(2));
if (!args.composition) die("missing <composition-dir>");
const SAMPLES = Number(args.samples ?? 10);
const OUT_DIR = resolve(args.out ?? ".hyperframes/contrast");
const WIDTH = Number(args.width ?? 1920);
const HEIGHT = Number(args.height ?? 1080);
const FPS = Number(args.fps ?? 30);
const COMP_DIR = resolve(args.composition);
// ─── Main ────────────────────────────────────────────────────────────────────
await mkdir(OUT_DIR, { recursive: true });
const server = await createFileServer({ projectDir: COMP_DIR, port: 0 });
const session = await createCaptureSession(
server.url,
OUT_DIR,
{ width: WIDTH, height: HEIGHT, fps: FPS, format: "png" },
null,
);
await initializeSession(session);
try {
const duration = await getCompositionDuration(session);
const times = Array.from(
{ length: SAMPLES },
(_, i) => +(((i + 0.5) / SAMPLES) * duration).toFixed(3),
);
const allEntries = [];
const overlayFrames = [];
for (let i = 0; i < times.length; i++) {
const t = times[i];
// Visible frame — used only for the human-facing overlay image.
const { buffer: pngBuf } = await captureFrameToBuffer(session, i, t);
// Hides each candidate's own text paint and returns its selector/fg/bbox.
const candidates = await prepareTextElements(session);
let elements;
try {
// Deliberately session.page.screenshot(), not captureFrameToBuffer —
// see the header comment for why.
const hiddenB64 = await session.page.screenshot({ encoding: "base64", type: "png" });
elements = await measureAgainstHiddenTextFrame(hiddenB64, candidates);
} finally {
await restoreTextElements(session);
}
const annotated = await annotateFrame(pngBuf, elements);
overlayFrames.push({ t, png: annotated });
for (const el of elements) allEntries.push({ time: t, ...el });
}
const report = {
composition: COMP_DIR,
width: WIDTH,
height: HEIGHT,
duration,
samples: times,
entries: allEntries,
summary: summarize(allEntries),
};
await writeFile(resolve(OUT_DIR, "contrast-report.json"), JSON.stringify(report, null, 2));
await writeOverlaySprite(overlayFrames, resolve(OUT_DIR, "contrast-overlay.png"));
printSummary(report);
process.exitCode = report.summary.failAA > 0 ? 1 : 0;
} finally {
await closeCaptureSession(session).catch(() => {});
server.close();
}
// ─── DOM probe + text-hide (runs in the page) ────────────────────────────────
// Walks the DOM for text-bearing elements, computes each one's foreground
// paint, and hides that element's own text (color/fill → transparent,
// !important, layout-neutral) so the caller's next screenshot reveals the
// real pixels behind the glyphs. Returns the candidate list; call
// restoreTextElements() afterward (in a finally) to undo the hide.
async function prepareTextElements(session) {
return await session.page.evaluate(() => {
/** @type {Array<{selector: string, text: string, fg: [number,number,number,number], fontSize: number, fontWeight: number, bbox: {x:number,y:number,w:number,h:number}}>} */
const out = [];
const restores = [];
// Registered BEFORE the walk starts and pushed to incrementally as each
// element is hidden: if something in the walk throws partway through,
// everything hidden so far is still reachable for restore instead of
// leaking hidden indefinitely.
window.__contrastReportRestores = restores;
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
const parseColor = (c) => {
const m = c.match(/rgba?\(([^)]+)\)/);
if (!m) return [0, 0, 0, 1];
const parts = m[1].split(",").map((s) => parseFloat(s.trim()));
return [parts[0], parts[1], parts[2], parts[3] ?? 1];
};
// Like parseColor, but returns null instead of defaulting to black when
// the value isn't a solid rgb()/rgba() color — e.g. SVG paint keywords
// such as "none"/"context-fill", or a gradient/pattern reference like
// 'url("#grad")'. Callers should fall back to another source of truth
// rather than trust a fabricated black.
const tryParseSolidColor = (c) => {
const m = c.match(/rgba?\(([^)]+)\)/);
if (!m) return null;
const parts = m[1].split(",").map((s) => parseFloat(s.trim()));
if (parts.some((v) => Number.isNaN(v))) return null;
return [parts[0], parts[1], parts[2], parts[3] ?? 1];
};
// SVG text (<text>, <tspan>, <textPath>) is painted via the `fill`
// property, not `color` — the two are independent CSS properties in
// SVG. A page can set `fill` without ever touching `color`, in which
// case getComputedStyle(el).color resolves to the inherited/initial
// value (often black) and does not reflect what's actually rendered.
const isSvgTextElement = (el) => !!el.ownerSVGElement;
const selectorOf = (el) => {
if (el.id) return `#${el.id}`;
const cls = [...el.classList].slice(0, 2).join(".");
return cls ? `${el.tagName.toLowerCase()}.${cls}` : el.tagName.toLowerCase();
};
let el;
while ((el = walker.nextNode())) {
// must have direct text
const direct = [...el.childNodes].some(
(n) => n.nodeType === 3 && n.textContent.trim().length,
);
if (!direct) continue;
const cs = getComputedStyle(el);
if (cs.visibility === "hidden" || cs.display === "none") continue;
if (parseFloat(cs.opacity) <= 0.01) continue;
const rect = el.getBoundingClientRect();
if (rect.width < 8 || rect.height < 8) continue;
const isSvgText = isSvgTextElement(el);
const fg = isSvgText
? tryParseSolidColor(cs.fill) || parseColor(cs.color)
: parseColor(cs.color);
if (fg[3] <= 0.01) continue;
// A `transition` on color/fill would otherwise animate this hide
// instead of applying it instantly — the screenshot taken right after
// can catch a partially-transparent glyph mid-transition instead of a
// fully hidden one, contaminating the background sample. Force
// `transition: none` alongside color/fill so the hide is atomic.
const origTransition = el.style.getPropertyValue("transition");
const origTransitionPriority = el.style.getPropertyPriority("transition");
el.style.setProperty("transition", "none", "important");
const origColor = el.style.getPropertyValue("color");
const origColorPriority = el.style.getPropertyPriority("color");
el.style.setProperty("color", "transparent", "important");
let origFill = null;
let origFillPriority = null;
if (isSvgText) {
origFill = el.style.getPropertyValue("fill");
origFillPriority = el.style.getPropertyPriority("fill");
el.style.setProperty("fill", "transparent", "important");
}
restores.push({
el,
origTransition,
origTransitionPriority,
origColor,
origColorPriority,
origFill,
origFillPriority,
isSvgText,
});
out.push({
selector: selectorOf(el),
text: el.textContent.trim().slice(0, 60),
fg,
fontSize: parseFloat(cs.fontSize),
fontWeight: Number(cs.fontWeight) || 400,
bbox: { x: rect.x, y: rect.y, w: rect.width, h: rect.height },
});
}
return out;
});
}
async function restoreTextElements(session) {
await session.page.evaluate(() => {
const restores = window.__contrastReportRestores;
if (!restores) return;
for (const r of restores) {
if (r.origColor) r.el.style.setProperty("color", r.origColor, r.origColorPriority);
else r.el.style.removeProperty("color");
if (r.isSvgText) {
if (r.origFill) r.el.style.setProperty("fill", r.origFill, r.origFillPriority);
else r.el.style.removeProperty("fill");
}
if (r.origTransition) {
r.el.style.setProperty("transition", r.origTransition, r.origTransitionPriority);
} else r.el.style.removeProperty("transition");
}
window.__contrastReportRestores = null;
});
}
// ─── Pixel sampling + WCAG math ──────────────────────────────────────────────
// Samples the REAL composited background directly inside each candidate's
// own bbox, from a screenshot taken with every candidate's text hidden —
// robust to panel edges, backdrop-filter blur, and translucent decoration
// in ways a proximity-based ring outside the bbox isn't. Mirrors
// packages/cli/src/commands/contrast-sample.ts's computeSampleRect /
// sampleGridPoints (kept in sync, not imported — this script bootstraps
// npm-published packages and can't reach into the cli package's sources).
async function measureAgainstHiddenTextFrame(hiddenImgBase64, candidates) {
const raw = Buffer.from(hiddenImgBase64, "base64");
const img = sharp(raw);
const { width, height } = await img.metadata();
const pixels = await img.ensureAlpha().raw().toBuffer();
const channels = 4;
const measured = [];
for (const c of candidates) {
const bg = sampleBboxMedian(pixels, width, height, channels, c.bbox);
if (!bg) continue;
const fg = compositeOver(c.fg, bg); // flatten any alpha against measured bg
const ratio = wcagRatio(fg, bg);
const large = isLargeText(c.fontSize, c.fontWeight);
measured.push({
selector: c.selector,
text: c.text,
fg,
fontSize: c.fontSize,
fontWeight: c.fontWeight,
bbox: c.bbox,
bg,
ratio: +ratio.toFixed(2),
wcagAA: large ? ratio >= 3 : ratio >= 4.5,
wcagAALarge: ratio >= 3,
wcagAAA: large ? ratio >= 4.5 : ratio >= 7,
});
}
return measured;
}
async function annotateFrame(pngBuf, elements) {
const { width, height } = await sharp(pngBuf).metadata();
// Draw boxes + ratio labels as an SVG overlay (sharp composite).
const svg = buildOverlaySVG(elements, width, height);
return await sharp(pngBuf)
.composite([{ input: Buffer.from(svg), top: 0, left: 0 }])
.png()
.toBuffer();
}
function sampleBboxMedian(raw, width, height, channels, bbox) {
// Sample the element's OWN box (glyphs are hidden in this frame), inset
// 1px on each side to dodge anti-aliased edge pixels, clamped to the
// frame bounds. A bounded grid, not a full scan, so a wide caption bar
// doesn't turn into thousands of samples.
const x0 = Math.max(0, Math.round(bbox.x) + 1);
const x1 = Math.min(width - 1, Math.round(bbox.x + bbox.w) - 1);
const y0 = Math.max(0, Math.round(bbox.y) + 1);
const y1 = Math.min(height - 1, Math.round(bbox.y + bbox.h) - 1);
if (x1 <= x0 || y1 <= y0) return null;
const stepX = Math.max(1, Math.floor((x1 - x0) / 12));
const stepY = Math.max(1, Math.floor((y1 - y0) / 6));
const r = [],
g = [],
b = [];
for (let y = y0; y <= y1; y += stepY) {
for (let x = x0; x <= x1; x += stepX) {
const i = (y * width + x) * channels;
r.push(raw[i]);
g.push(raw[i + 1]);
b.push(raw[i + 2]);
}
}
if (r.length === 0) return null;
return [median(r), median(g), median(b), 1];
}
function median(arr) {
const s = [...arr].sort((a, b) => a - b);
return s[Math.floor(s.length / 2)];
}
function compositeOver([fr, fg, fb, fa], [br, bg, bb]) {
return [
Math.round(fr * fa + br * (1 - fa)),
Math.round(fg * fa + bg * (1 - fa)),
Math.round(fb * fa + bb * (1 - fa)),
1,
];
}
function relLum([r, g, b]) {
const ch = (v) => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b);
}
function wcagRatio(a, b) {
const la = relLum(a);
const lb = relLum(b);
const [L1, L2] = la > lb ? [la, lb] : [lb, la];
return (L1 + 0.05) / (L2 + 0.05);
}
function isLargeText(fontSize, fontWeight) {
return fontSize >= 24 || (fontSize >= 19 && fontWeight >= 700);
}
// ─── Overlay rendering ───────────────────────────────────────────────────────
function buildOverlaySVG(elements, w, h) {
const rects = elements
.map((el) => {
const color = !el.wcagAA ? "#ff00aa" : !el.wcagAAA ? "#ffcc00" : "#00e08a";
const { x, y, w: bw, h: bh } = el.bbox;
return `
<rect x="${x}" y="${y}" width="${bw}" height="${bh}"
fill="none" stroke="${color}" stroke-width="3"/>
<rect x="${x}" y="${y - 18}" width="${48}" height="16" fill="${color}"/>
<text x="${x + 4}" y="${y - 5}" font-family="monospace" font-size="12" fill="#000">
${el.ratio.toFixed(1)}:1
</text>`;
})
.join("");
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}">${rects}</svg>`;
}
async function writeOverlaySprite(frames, outPath) {
if (!frames.length) return;
const cols = Math.min(frames.length, 5);
const rows = Math.ceil(frames.length / cols);
const { width, height } = await sharp(frames[0].png).metadata();
const scale = 0.25;
const cellW = Math.round(width * scale);
const cellH = Math.round(height * scale);
const cells = await Promise.all(
frames.map(async (f) => ({
input: await sharp(f.png).resize(cellW, cellH).png().toBuffer(),
time: f.t,
})),
);
const composites = cells.map((c, i) => ({
input: c.input,
top: Math.floor(i / cols) * cellH,
left: (i % cols) * cellW,
}));
await sharp({
create: {
width: cols * cellW,
height: rows * cellH,
channels: 3,
background: { r: 16, g: 16, b: 20 },
},
})
.composite(composites)
.png()
.toFile(outPath);
}
// ─── Summary ────────────────────────────────────────────────────────────────
function summarize(entries) {
const total = entries.length;
const failAA = entries.filter((e) => !e.wcagAA).length;
const passAAonly = entries.filter((e) => e.wcagAA && !e.wcagAAA).length;
const passAAA = entries.filter((e) => e.wcagAAA).length;
return { total, failAA, passAAonly, passAAA };
}
function printSummary({ summary, entries }) {
const { total, failAA, passAAonly, passAAA } = summary;
console.log(`\nContrast report: ${total} text-element samples`);
console.log(` fail WCAG AA: ${failAA}`);
console.log(` pass AA, not AAA: ${passAAonly}`);
console.log(` pass AAA: ${passAAA}`);
if (failAA) {
console.log("\nFailures:");
for (const e of entries.filter((x) => !x.wcagAA)) {
console.log(` t=${e.time}s ${e.selector.padEnd(24)} ${e.ratio.toFixed(2)}:1 "${e.text}"`);
}
}
}
// ─── Utilities ──────────────────────────────────────────────────────────────
function parseArgs(argv) {
const out = {};
let positional = 0;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith("--")) {
const k = a.slice(2);
const v = argv[i + 1]?.startsWith("--") ? true : argv[++i];
out[k] = v;
} else if (positional === 0) {
out.composition = a;
positional++;
}
}
return out;
}
function die(msg) {
console.error(`contrast-report: ${msg}`);
process.exit(2);
}