mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
## 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.
90 lines
3.7 KiB
TypeScript
90 lines
3.7 KiB
TypeScript
// Pure background-sampling-region logic for the WCAG contrast audit.
|
|
//
|
|
// The audit used to estimate an element's background by sampling a 4px ring
|
|
// just OUTSIDE its bounding box (with an own-opaque-background pre-check —
|
|
// see the historical note in contrast-bg.ts). That proximity-based estimate
|
|
// breaks down whenever what's immediately outside the box differs from
|
|
// what's actually behind the text inside it:
|
|
//
|
|
// - cross-component bleed: text sits near the edge of its own panel/layer,
|
|
// and a differently-colored sibling panel/layer starts just outside the
|
|
// text's bbox — the ring samples the neighbor, not the true background.
|
|
// - solid-fill pill/button with rounded corners achieved via a sibling
|
|
// shape (not a CSS background-color on an ancestor of the text) — same
|
|
// failure as above; the ownBg ancestor-walk never sees it.
|
|
// - translucent "glass" text over a backdrop-filter blur panel sized only
|
|
// a couple pixels larger than the text — the ring exits the panel and
|
|
// samples the raw, unblurred, untinted pixels behind it.
|
|
// - a translucent/decorative shape that only partially overlaps the ring,
|
|
// or sits entirely INSIDE the text's own bbox (never touching the ring
|
|
// at all) — the ring is structurally blind to it.
|
|
//
|
|
// The fix: hide the text's own paint (color/fill → transparent), take ONE
|
|
// screenshot with the glyphs invisible, then sample the REAL composited
|
|
// pixels directly INSIDE the element's own bbox — no proximity heuristic
|
|
// needed, because we're reading the exact pixels that were behind the
|
|
// glyphs. This module hosts the pure "which rect do we sample" decision
|
|
// (inset to dodge anti-aliased edge pixels, clamp to canvas bounds, and
|
|
// reject a rect that's too small to sample) so it's unit-testable without a
|
|
// browser. The same logic is inlined into contrast-audit.browser.js (which
|
|
// is injected as a raw string and cannot import) and into
|
|
// skills/hyperframes-creative/scripts/contrast-report.mjs — keep all three
|
|
// in sync.
|
|
|
|
export interface Rect {
|
|
x: number;
|
|
y: number;
|
|
w: number;
|
|
h: number;
|
|
}
|
|
|
|
export interface PixelRect {
|
|
x0: number;
|
|
x1: number;
|
|
y0: number;
|
|
y1: number;
|
|
}
|
|
|
|
/**
|
|
* Compute the region to sample for an element's true background: its own
|
|
* bbox, inset by 1px on each side (anti-aliased glyph/box edges bleed into
|
|
* the adjacent pixel and shouldn't count as "background"), clamped to the
|
|
* screenshot's pixel bounds.
|
|
*
|
|
* Returns null when nothing usable survives — the bbox is entirely outside
|
|
* the canvas, or is too small once inset to contain any interior pixel.
|
|
*/
|
|
export function computeSampleRect(
|
|
bbox: Rect,
|
|
canvasWidth: number,
|
|
canvasHeight: number,
|
|
): PixelRect | null {
|
|
const x0 = Math.max(0, Math.round(bbox.x) + 1);
|
|
const x1 = Math.min(canvasWidth - 1, Math.round(bbox.x + bbox.w) - 1);
|
|
const y0 = Math.max(0, Math.round(bbox.y) + 1);
|
|
const y1 = Math.min(canvasHeight - 1, Math.round(bbox.y + bbox.h) - 1);
|
|
if (x1 <= x0 || y1 <= y0) return null;
|
|
return { x0, x1, y0, y1 };
|
|
}
|
|
|
|
/**
|
|
* Generate a bounded grid of sample coordinates within a pixel rect — dense
|
|
* enough to catch a partially-overlapping decoration, capped so a large
|
|
* caption bar doesn't turn into a full pixel scan.
|
|
*/
|
|
export function sampleGridPoints(
|
|
rect: PixelRect,
|
|
maxCols = 12,
|
|
maxRows = 6,
|
|
): Array<[number, number]> {
|
|
const stepX = Math.max(1, Math.floor((rect.x1 - rect.x0) / maxCols));
|
|
const stepY = Math.max(1, Math.floor((rect.y1 - rect.y0) / maxRows));
|
|
const points: Array<[number, number]> = [];
|
|
for (let y = rect.y0; y <= rect.y1; y += stepY) {
|
|
for (let x = rect.x0; x <= rect.x1; x += stepX) {
|
|
points.push([x, y]);
|
|
}
|
|
}
|
|
return points;
|
|
}
|