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.
This commit is contained in:
Miguel Ángel
2026-07-09 18:01:48 -04:00
committed by GitHub
parent dff27cb1df
commit 1614dd3e5a
10 changed files with 887 additions and 184 deletions
+56 -9
View File
@@ -281,6 +281,16 @@ async function auditClipDurations(
return warnings;
}
interface ContrastCandidate {
selector: string;
text: string;
fg: [number, number, number, number];
fontSize: number;
fontWeight: number;
large: boolean;
bbox: { x: number; y: number; w: number; h: number };
}
async function runContrastAudit(page: import("puppeteer-core").Page): Promise<ContrastEntry[]> {
const duration = await getCompositionDuration(page);
if (duration <= 0) return [];
@@ -292,16 +302,53 @@ async function runContrastAudit(page: import("puppeteer-core").Page): Promise<Co
const t = +(((i + 0.5) / CONTRAST_SAMPLES) * duration).toFixed(3);
await seekTo(page, t);
const screenshot = (await page.screenshot({ encoding: "base64", type: "png" })) as string;
const entries = await page.evaluate(
(b64: string, time: number) =>
typeof (window as unknown as Record<string, unknown>).__contrastAudit === "function"
? ((window as unknown as Record<string, unknown>).__contrastAudit as Function)(b64, time)
try {
// __contrastAuditPrepare() hides each candidate text element's own
// paint (color/fill → transparent, layout-neutral) so this screenshot
// captures the real pixels behind the glyphs — that's what
// __contrastAuditFinish samples directly instead of a proximity-based
// ring outside the text's bbox. See contrast-audit.browser.js for why:
// it's robust to rounded pills, cross-component panel edges,
// backdrop-filter blur, and partially-overlapping translucent
// decoration in ways a ring isn't.
//
// This call is the FIRST statement inside the try — not before it —
// so if prepare() itself throws partway through hiding elements, the
// finally below still runs and restores whatever it managed to hide.
const candidates = (await page.evaluate(() =>
typeof (window as unknown as Record<string, unknown>).__contrastAuditPrepare === "function"
? (
(window as unknown as Record<string, unknown>).__contrastAuditPrepare as () => unknown
)()
: [],
screenshot,
t,
);
results.push(...(entries as ContrastEntry[]));
)) as ContrastCandidate[];
const screenshot = (await page.screenshot({ encoding: "base64", type: "png" })) as string;
const entries = await page.evaluate(
(b64: string, time: number, cands: ContrastCandidate[]) =>
typeof (window as unknown as Record<string, unknown>).__contrastAuditFinish === "function"
? ((window as unknown as Record<string, unknown>).__contrastAuditFinish as Function)(
b64,
time,
cands,
)
: [],
screenshot,
t,
candidates,
);
results.push(...(entries as ContrastEntry[]));
} finally {
// If prepare(), the screenshot, or finish() above throws, this restores
// any still-hidden text paint so the NEXT sample in the loop doesn't
// audit a page with stale invisible elements. No-op after a normal
// finish() call.
await page.evaluate(() => {
const restore = (window as unknown as Record<string, unknown>)
.__contrastAuditRestoreIfPending;
if (typeof restore === "function") (restore as () => void)();
});
}
}
return results;