mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -51,6 +51,18 @@ afterEach(() => {
|
||||
Reflect.deleteProperty(window, "__contrastAuditRestoreIfPending");
|
||||
});
|
||||
|
||||
function installSessionMock(page: ReturnType<typeof fakePage>): void {
|
||||
const browser = Object.assign(Object.create(null), {
|
||||
close: vi.fn(async () => undefined),
|
||||
});
|
||||
vi.mocked(openSettledCompositionPage).mockImplementation(
|
||||
async (_html: string, _url: string, options: OpenSettledCompositionPageOptions) => {
|
||||
await options.beforeNavigate?.(page);
|
||||
return { page, browser, renderReadyTimedOut: false };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
it("carries raw browser geometry through the page driver and pipeline", async () => {
|
||||
vi.spyOn(Date, "now")
|
||||
.mockReturnValueOnce(100)
|
||||
@@ -68,15 +80,7 @@ it("carries raw browser geometry through the page driver and pipeline", async ()
|
||||
Object.defineProperty(window, "innerHeight", { configurable: true, value: 360 });
|
||||
installRects();
|
||||
const page = fakePage();
|
||||
const browser = Object.assign(Object.create(null), {
|
||||
close: vi.fn(async () => undefined),
|
||||
});
|
||||
vi.mocked(openSettledCompositionPage).mockImplementation(
|
||||
async (_html: string, _url: string, options: OpenSettledCompositionPageOptions) => {
|
||||
await options.beforeNavigate?.(page);
|
||||
return { page, browser, renderReadyTimedOut: false };
|
||||
},
|
||||
);
|
||||
installSessionMock(page);
|
||||
|
||||
const result = await runBrowserCheck(
|
||||
PROJECT,
|
||||
@@ -184,15 +188,7 @@ it("round-trips the browser script's raw contrast candidates back into finish",
|
||||
}
|
||||
});
|
||||
page.screenshot = vi.fn(async () => "c3R1Yg==");
|
||||
const browser = Object.assign(Object.create(null), {
|
||||
close: vi.fn(async () => undefined),
|
||||
});
|
||||
vi.mocked(openSettledCompositionPage).mockImplementation(
|
||||
async (_html: string, _url: string, options: OpenSettledCompositionPageOptions) => {
|
||||
await options.beforeNavigate?.(page);
|
||||
return { page, browser, renderReadyTimedOut: false };
|
||||
},
|
||||
);
|
||||
installSessionMock(page);
|
||||
|
||||
await runBrowserCheck(
|
||||
PROJECT,
|
||||
|
||||
@@ -632,21 +632,45 @@ export function checkExitCode(report: CheckReport): 0 | 1 {
|
||||
return report.ok ? 0 : 1;
|
||||
}
|
||||
|
||||
// Same persistence rule the layout findings follow: a failure observed at a
|
||||
// single contrast sample is usually text caught mid-entrance/exit (its real
|
||||
// background not painted yet — white-on-white at exactly 1.0 is the classic
|
||||
// shape), so it demotes to warning. A failure HELD at 2+ samples for the same
|
||||
// element is a real, gating defect. Single-sample sweeps can't distinguish,
|
||||
// so they keep full severity.
|
||||
function contrastFailureHeld(
|
||||
entries: ContrastAuditEntry[],
|
||||
): (entry: ContrastAuditEntry) => boolean {
|
||||
const sampledTimes = new Set(entries.map((entry) => entry.time)).size;
|
||||
const failureSamples = new Map<string, Set<number>>();
|
||||
for (const entry of entries) {
|
||||
if (entry.wcagAA) continue;
|
||||
const key = `${entry.selector}|${entry.text}`;
|
||||
const times = failureSamples.get(key) ?? new Set<number>();
|
||||
times.add(entry.time);
|
||||
failureSamples.set(key, times);
|
||||
}
|
||||
return (entry) =>
|
||||
sampledTimes < 2 || (failureSamples.get(`${entry.selector}|${entry.text}`)?.size ?? 0) >= 2;
|
||||
}
|
||||
|
||||
function buildContrastResults(entries: ContrastAuditEntry[]): {
|
||||
findings: CheckContrastFinding[];
|
||||
passed: number;
|
||||
} {
|
||||
const findings: CheckContrastFinding[] = [];
|
||||
let passed = 0;
|
||||
const isHeld = contrastFailureHeld(entries);
|
||||
for (const entry of entries) {
|
||||
if (entry.wcagAA) {
|
||||
passed += 1;
|
||||
continue;
|
||||
}
|
||||
const held = isHeld(entry);
|
||||
const requiredRatio = requiredContrastRatio(entry.large);
|
||||
findings.push({
|
||||
code: "contrast_aa_failure",
|
||||
severity: "error",
|
||||
severity: held ? "error" : "warning",
|
||||
message: `Contrast is ${entry.ratio}:1; WCAG AA requires ${requiredRatio}:1.`,
|
||||
text: entry.text,
|
||||
fg: entry.fg,
|
||||
|
||||
Reference in New Issue
Block a user