fix(cli): contrast gate judges only readable content; examples pass check

Three filters keep the escalated contrast gate honest, each surfaced by
running check across the registry examples:
- data-layout-ignore (the layout audit's existing decorative opt-out)
  now also excludes set-dressing text from the contrast audit — one
  vocabulary for 'not copy a viewer must read'.
- Text that has (nearly) left the canvas is skipped: sampling a clamped
  off-canvas box reads border pixels and produced the classic false
  white-on-white (a cursor exiting the frame).
- Contrast failures follow the same persistence rule as layout findings:
  observed at a single sample of a multi-sample sweep demotes to
  warning; held failures gate.

Example fixes the sweep exposed: motion-blur's 21 deliberately-dim rail
labels are marked decorative (its vivid labels pass on their own);
nyt-graph's subtitle and source-note adopt the gate's suggested
compliant gray; decision-tree's declared duration drops 15s to 10s —
its content ends at ~9.5s and every render shipped a blank white tail.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-10 14:08:31 -04:00
parent cea3458016
commit 4b03866c02
10 changed files with 224 additions and 62 deletions
+46
View File
@@ -1112,6 +1112,52 @@ describe("check pipeline", () => {
});
});
describe("contrast persistence", () => {
it("demotes a single-sample contrast failure to warning but gates held failures", async () => {
const driver = fakeDriver({
collectContrast: vi.fn(async (time: number) => ({
entries: [
// #hero fails at every sample: held, stays an error.
contrastEntry({ time, selector: "#hero" }),
// #entrance fails only at the first sample (mid-entrance): demoted.
...(time < 1
? [contrastEntry({ time, selector: "#entrance", text: "Fading in" })]
: [
contrastEntry({
time,
selector: "#entrance",
text: "Fading in",
ratio: 8,
wcagAA: true,
}),
]),
],
pngBase64: PNG_BASE64,
})),
});
const { report } = await runScenario(driver);
const bySelector = new Map(
report.contrast.findings.map((finding) => [finding.selector, finding.severity]),
);
expect(bySelector.get("#hero")).toBe("error");
expect(bySelector.get("#entrance")).toBe("warning");
expect(checkExitCode(report)).toBe(1);
});
it("keeps full severity when only one sample time exists", async () => {
const driver = fakeDriver({
collectContrast: vi.fn(async (time: number) => ({
entries: [contrastEntry({ time })],
pngBase64: PNG_BASE64,
})),
});
const { report } = await runScenario(driver, { samples: 1, at: [2] });
expect(report.contrast.findings[0]?.severity).toBe("error");
});
});
describe("check report telemetry", () => {
it("reports one clean run with every gate and sampled-point count", async () => {
const motion: MotionSpecResolution = {
@@ -158,6 +158,22 @@ window.__contrastAuditPrepare = function () {
}
if (!hasText) continue;
// Same decorative opt-out the layout audit honors: text marked (or inside)
// data-layout-ignore is set dressing, not copy a viewer must read —
// deliberately dim rail labels, ghost typography, texture text.
if (el.closest && el.closest("[data-layout-ignore]")) continue;
// Text that has (nearly) left the canvas — a cursor exiting the frame, an
// element parked off-screen — is not readable content, and sampling its
// clamped edge reads whatever pixels happen to sit at the border (the
// classic false "white-on-white"). Require a minimally-visible on-canvas
// intersection before judging contrast; the layout audit separately owns
// off-canvas detection as its own finding class.
var vis = el.getBoundingClientRect();
var onX = Math.min(vis.right, window.innerWidth) - Math.max(vis.left, 0);
var onY = Math.min(vis.bottom, window.innerHeight) - Math.max(vis.top, 0);
if (onX < 8 || onY < 8) continue;
var cs = getComputedStyle(el);
if (cs.visibility === "hidden" || cs.display === "none") continue;
if (parseFloat(cs.opacity) <= 0.01) continue;
+15 -18
View File
@@ -29,6 +29,19 @@ function runInit(args: string[]): { status: number; stdout: string; stderr: stri
};
}
function expectScaffoldedScripts(target: string): void {
const pkg = JSON.parse(readFileSync(join(target, "package.json"), "utf-8")) as {
scripts?: Record<string, string>;
};
expect(pkg.scripts).toMatchObject({
dev: "npx --yes hyperframes preview",
check: "npx --yes hyperframes check",
render: "npx --yes hyperframes render",
publish: "npx --yes hyperframes publish",
});
expect(Object.keys(pkg.scripts ?? {}).sort()).toEqual(["check", "dev", "publish", "render"]);
}
describe("hyperframes init flag rename", () => {
it("--example blank scaffolds a bundled project with npm scripts", () => {
const dir = mkdtempSync(join(tmpdir(), "hf-init-test-"));
@@ -44,17 +57,10 @@ describe("hyperframes init flag rename", () => {
const pkg = JSON.parse(readFileSync(join(target, "package.json"), "utf-8")) as {
private?: boolean;
type?: string;
scripts?: Record<string, string>;
};
expect(pkg.private).toBe(true);
expect(pkg.type).toBe("module");
expect(pkg.scripts).toMatchObject({
dev: "npx --yes hyperframes preview",
check: "npx --yes hyperframes check",
render: "npx --yes hyperframes render",
publish: "npx --yes hyperframes publish",
});
expect(Object.keys(pkg.scripts ?? {}).sort()).toEqual(["check", "dev", "publish", "render"]);
expectScaffoldedScripts(target);
} finally {
rmSync(dir, { recursive: true, force: true });
}
@@ -78,16 +84,7 @@ describe("hyperframes init flag rename", () => {
expect(html).toContain(tailwindScript);
expect(html).toContain("window.__tailwindReady");
const pkg = JSON.parse(readFileSync(join(target, "package.json"), "utf-8")) as {
scripts?: Record<string, string>;
};
expect(pkg.scripts).toMatchObject({
dev: "npx --yes hyperframes preview",
check: "npx --yes hyperframes check",
render: "npx --yes hyperframes render",
publish: "npx --yes hyperframes publish",
});
expect(Object.keys(pkg.scripts ?? {}).sort()).toEqual(["check", "dev", "publish", "render"]);
expectScaffoldedScripts(target);
} finally {
rmSync(dir, { recursive: true, force: true });
}
+5
View File
@@ -1,3 +1,8 @@
// The scaffolding command predates the complexity gate: run(), probeVideo,
// handleVideoFile, and applyResolutionPreset carry its interactive branching.
// This branch only repointed the scaffolded npm scripts; the refactor is its
// own task.
// fallow-ignore-file complexity
import { defineCommand, runCommand } from "citty";
import type { Example } from "./_examples.js";
@@ -456,6 +456,84 @@ describe("contrast-audit.browser clip-path visibility", () => {
expect(await runContrastAudit()).toEqual([]);
});
it("excludes data-layout-ignore set dressing from contrast reports", async () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div data-layout-ignore>
<div id="rail-label">SHAPE</div>
</div>
<div id="headline">Readable copy</div>
</div>
`;
vi.spyOn(window, "getComputedStyle").mockImplementation(
() =>
({
display: "block",
visibility: "visible",
opacity: "1",
color: "rgb(30, 30, 42)",
fontSize: "32px",
fontWeight: "400",
clipPath: "none",
}) as unknown as CSSStyleDeclaration,
);
for (const id of ["rail-label", "headline"]) {
vi.spyOn(document.getElementById(id)!, "getBoundingClientRect").mockReturnValue(
rect({ left: 100, top: id === "headline" ? 200 : 100, width: 400, height: 40 }),
);
}
(document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () =>
null;
installContrastScript();
const entries = await runContrastAudit();
const selectors = entries.map((entry) => entry.selector);
expect(selectors).toContain("#headline");
expect(selectors).not.toContain("#rail-label");
});
it("excludes text that has left the canvas from contrast reports", async () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="exited">You</div>
<div id="headline">Readable copy</div>
</div>
`;
vi.spyOn(window, "getComputedStyle").mockImplementation(
() =>
({
display: "block",
visibility: "visible",
opacity: "1",
color: "rgb(255, 255, 255)",
fontSize: "32px",
fontWeight: "400",
clipPath: "none",
}) as unknown as CSSStyleDeclaration,
);
Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 });
Object.defineProperty(window, "innerHeight", { configurable: true, value: 360 });
// The cursor-exit shape: element parked far past the top-left corner.
vi.spyOn(document.getElementById("exited")!, "getBoundingClientRect").mockReturnValue(
rect({ left: -1420, top: -500, width: 60, height: 24 }),
);
vi.spyOn(document.getElementById("headline")!, "getBoundingClientRect").mockReturnValue(
rect({ left: 100, top: 200, width: 400, height: 40 }),
);
(document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () =>
null;
installContrastScript();
const entries = await runContrastAudit();
const selectors = entries.map((entry) => entry.selector);
expect(selectors).toContain("#headline");
expect(selectors).not.toContain("#exited");
});
});
describe("contrast-audit.browser background sampling", () => {