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 = {