mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user