mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
feat(cli): opt-in check flag that leaves out contrast measured mid-animation (#6396)
* feat(cli): opt-in check flag that leaves out contrast measured mid-animation `hyperframes check --skip-contrast-in-motion`, or HYPERFRAMES_CHECK_SKIP_CONTRAST_IN_MOTION=1, leaves out a contrast failure measured while a GSAP tween on the text or an ancestor is running, or while the text's cumulative opacity is below 1. Repeating tweens and tweens of 4 s or longer (camera drifts) do not count as motion. Text no GSAP tween targets is reported as before. With neither set, check is unchanged. The motion state is read at the same contrast sample, with no extra seek or screenshot. * fix(cli): read contrast motion on current GSAP clocks * fix(cli): keep infinite timeline motion ambient
This commit is contained in:
@@ -1058,6 +1058,7 @@ cover every finding from the full check.
|
||||
| `--tolerance` | Allowed overflow in px before reporting (default 2) |
|
||||
| `--timeout` | Render-ready budget in ms; also raises page navigation above its 10s floor (default 3000) |
|
||||
| `--no-contrast` | Skip the WCAG pass while iterating |
|
||||
| `--skip-contrast-in-motion` | Leave out contrast failures measured mid-animation (see below); also `HYPERFRAMES_CHECK_SKIP_CONTRAST_IN_MOTION=1` |
|
||||
| `--strict` | Exit non-zero on warnings too (default: errors only) |
|
||||
| `--caption-zone "<x0=..;y0=..;x1=..;y1=..>"` | Opt-in band gate. Flags a text element's DOM box that overlaps the fractional band. Optional `severity` and `seek`. |
|
||||
| `--frame-check` | Opt-in out-of-frame detection for `img`, `svg`, `video`, and `canvas` |
|
||||
@@ -1097,6 +1098,18 @@ Contrast failures are errors, and each one reports the sampled foreground and
|
||||
background colours, the measured ratio against the required one, and a
|
||||
compliant colour you could use instead.
|
||||
|
||||
`--skip-contrast-in-motion`, or `HYPERFRAMES_CHECK_SKIP_CONTRAST_IN_MOTION=1` in the
|
||||
environment, leaves out a contrast failure measured while a GSAP tween on the text
|
||||
or an ancestor is running, or while the text's opacity multiplied up its ancestors
|
||||
is below 1. The failure is not reported, and the counts and `ok` leave it out.
|
||||
Repeating tweens, tweens inside infinitely repeating timelines, and tweens of
|
||||
4s or longer, such as a slow camera drift, do not count as motion. The opacity
|
||||
rule also applies at rest, including deliberately translucent text, when a tween
|
||||
targets the text or an ancestor. Text no GSAP tween targets is reported as usual.
|
||||
Dropped samples do not contribute to persistence: a retained failure can become
|
||||
a warning when only one failing sample remains among multiple measured times.
|
||||
Off by default.
|
||||
|
||||
Severity is persistence-aware: a finding at a single sample demotes to info, a
|
||||
finding that persists gates the exit code, and a composition of 3s or more
|
||||
fails with `sweep_static` when its frame never changes and a GSAP timeline or
|
||||
|
||||
@@ -432,6 +432,56 @@ describe("check --prepare-media", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("skip contrast in motion", () => {
|
||||
async function parsedOptions(rawArgs: string[]): Promise<CheckOptions | undefined> {
|
||||
const { report } = await runScenario(fakeDriver());
|
||||
const runPipeline = vi.fn(async (_project: ProjectDir, _options: CheckOptions) => report);
|
||||
vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
await runCommand(
|
||||
createCheckCommand({
|
||||
resolveProject: () => PROJECT,
|
||||
runPipeline,
|
||||
prepareMedia: vi.fn(async () => []),
|
||||
withMeta: (value) => value,
|
||||
}),
|
||||
{ rawArgs: ["--json", ...rawArgs] },
|
||||
);
|
||||
return runPipeline.mock.calls[0]?.[1];
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("is off unless the flag or the environment turns it on", async () => {
|
||||
vi.stubEnv("HYPERFRAMES_CHECK_SKIP_CONTRAST_IN_MOTION", "");
|
||||
expect(await parsedOptions([])).not.toHaveProperty("skipContrastInMotion");
|
||||
expect(await parsedOptions(["--skip-contrast-in-motion"])).toMatchObject({
|
||||
skipContrastInMotion: true,
|
||||
});
|
||||
vi.stubEnv("HYPERFRAMES_CHECK_SKIP_CONTRAST_IN_MOTION", "1");
|
||||
expect(await parsedOptions([])).toMatchObject({ skipContrastInMotion: true });
|
||||
expect(await parsedOptions(["--no-skip-contrast-in-motion"])).toMatchObject({
|
||||
skipContrastInMotion: true,
|
||||
});
|
||||
vi.stubEnv("HYPERFRAMES_CHECK_SKIP_CONTRAST_IN_MOTION", "0");
|
||||
expect(await parsedOptions([])).not.toHaveProperty("skipContrastInMotion");
|
||||
});
|
||||
|
||||
it("asks the driver to skip in-motion contrast only when enabled", async () => {
|
||||
const off = fakeDriver();
|
||||
await runScenario(off);
|
||||
for (const call of vi.mocked(off.collectContrast).mock.calls) expect(call).toHaveLength(1);
|
||||
|
||||
const on = fakeDriver();
|
||||
await runScenario(on, { skipContrastInMotion: true });
|
||||
expect(vi.mocked(on.collectContrast).mock.calls.length).toBeGreaterThan(0);
|
||||
for (const call of vi.mocked(on.collectContrast).mock.calls) {
|
||||
expect(call).toEqual([expect.any(Number), undefined, true]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("parses the caption-zone grammar and enables the frame gate", async () => {
|
||||
const { report } = await runScenario(fakeDriver());
|
||||
const runPipeline = vi.fn(async (_project: ProjectDir, _options: CheckOptions) => report);
|
||||
@@ -2751,6 +2801,33 @@ describe("contrast persistence", () => {
|
||||
expect(checkExitCode(report)).toBe(1);
|
||||
});
|
||||
|
||||
it("uses only retained samples for contrast persistence when motion filtering is enabled", async () => {
|
||||
const driver = fakeDriver({
|
||||
collectContrast: vi.fn(async (time: number, _annotations, skipInMotion) => ({
|
||||
entries: [
|
||||
contrastEntry({ time, selector: "#passing", ratio: 8, wcagAA: true }),
|
||||
...(skipInMotion && time < 1 ? [] : [contrastEntry({ time })]),
|
||||
],
|
||||
})),
|
||||
});
|
||||
const { report: off } = await runScenario(driver, { at: [0.5, 1.5] });
|
||||
const { report: on } = await runScenario(driver, {
|
||||
at: [0.5, 1.5],
|
||||
skipContrastInMotion: true,
|
||||
});
|
||||
expect(off.contrast.errorCount).toBe(1);
|
||||
expect(checkExitCode(off)).toBe(1);
|
||||
expect(on.contrast).toMatchObject({ errorCount: 0, warningCount: 1, checked: 3, passed: 2 });
|
||||
expect(on.ok).toBe(true);
|
||||
expect(checkExitCode(on)).toBe(0);
|
||||
const { report: strict } = await runScenario(driver, {
|
||||
at: [0.5, 1.5],
|
||||
skipContrastInMotion: true,
|
||||
strict: true,
|
||||
});
|
||||
expect(checkExitCode(strict)).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps full severity when only one sample time exists", async () => {
|
||||
const driver = fakeDriver({
|
||||
collectContrast: vi.fn(async (time: number) => ({
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "../utils/checkPipeline.js";
|
||||
import type { CaptionZoneOptions, FrameCheckOptions, LayoutOptions } from "../utils/checkTypes.js";
|
||||
import { resolveLocalBrowserGpuMode } from "../browser/gpuPolicy.js";
|
||||
import { envFlagEnabled } from "../utils/attendedTerminal.js";
|
||||
import { preResolveHostileMediaProxies, type PreparedMediaFile } from "../utils/checkBrowser.js";
|
||||
|
||||
export const examples: Example[] = [
|
||||
@@ -215,6 +216,12 @@ const CHECK_COMMAND_ARGS = {
|
||||
description: "Run the WCAG AA contrast pass (enabled by default)",
|
||||
default: true,
|
||||
},
|
||||
"skip-contrast-in-motion": {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Leave out contrast failures measured while a GSAP tween moves the text or an ancestor, or at partial opacity (also HYPERFRAMES_CHECK_SKIP_CONTRAST_IN_MOTION=1)",
|
||||
default: false,
|
||||
},
|
||||
strict: {
|
||||
type: "boolean",
|
||||
description: "Exit non-zero on warnings too",
|
||||
@@ -389,6 +396,10 @@ function parseCheckOptions(args: Record<string, unknown>): CheckOptions {
|
||||
tolerance: nonNegativeNumber(args.tolerance, DEFAULT_CHECK_OPTIONS.tolerance),
|
||||
timeout: Math.max(500, positiveInteger(args.timeout, DEFAULT_CHECK_OPTIONS.timeout)),
|
||||
contrast: args.contrast !== false,
|
||||
...(args["skip-contrast-in-motion"] === true ||
|
||||
envFlagEnabled("HYPERFRAMES_CHECK_SKIP_CONTRAST_IN_MOTION")
|
||||
? { skipContrastInMotion: true }
|
||||
: {}),
|
||||
strict: args.strict === true,
|
||||
snapshots: args.snapshots === true,
|
||||
captionZone: parseCaptionZone(args["caption-zone"]),
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import puppeteer, { type Browser, type Page } from "puppeteer-core";
|
||||
import { createRequire } from "node:module";
|
||||
import { resolve } from "node:path";
|
||||
import { measuredInMotion, readContrastMotion } from "./contrastMotion.js";
|
||||
import { collectSeekClock } from "./checkBrowser.js";
|
||||
|
||||
const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||
@@ -123,4 +126,28 @@ describe.runIf(executablePath)("collectSeekClock in Chromium", () => {
|
||||
expect(await collectSeekClock(page)).toEqual([]);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
it("reads real GSAP motion after a repeated seek without moving the clock", async () => {
|
||||
page = await browser.newPage();
|
||||
try {
|
||||
await page.setContent('<div id="card"><span id="label">Text</span></div>');
|
||||
const coreRequire = createRequire(resolve(__dirname, "../../../core/package.json"));
|
||||
await page.addScriptTag({ path: coreRequire.resolve("gsap/dist/gsap.js") });
|
||||
await page.evaluate(() => {
|
||||
const gsap = Reflect.get(window, "gsap");
|
||||
const root = gsap.timeline({ paused: true, repeat: 1, yoyo: true });
|
||||
root.to("#card", { x: 100, duration: 1 }, 1).to({}, { duration: 1 }, 3);
|
||||
root.totalTime(6.5, true);
|
||||
Reflect.set(window, "__timelines", { root });
|
||||
Reflect.set(window, "__contrastAuditRestores", [{ el: document.getElementById("label") }]);
|
||||
});
|
||||
const sample = (await page.evaluate(readContrastMotion))[0];
|
||||
expect(sample && measuredInMotion(sample)).toBe(true);
|
||||
expect(await page.evaluate(() => Reflect.get(window, "__timelines").root.totalTime())).toBe(
|
||||
6.5,
|
||||
);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -687,6 +687,136 @@ it.each([
|
||||
},
|
||||
);
|
||||
|
||||
describe("skipContrastInMotion", () => {
|
||||
type Tween = { target: string; start: number; duration: number; repeat?: number };
|
||||
|
||||
async function contrastFindings(
|
||||
tweens: Tween[],
|
||||
skipContrastInMotion: boolean | undefined,
|
||||
snapshots = false,
|
||||
) {
|
||||
vi.spyOn(Date, "now").mockReturnValue(100);
|
||||
mountCanvasFixture(`<div id="scene"><div id="headline">Low contrast copy</div></div>`);
|
||||
const root = document.querySelector("[data-composition-id]");
|
||||
const headline = document.querySelector("#headline");
|
||||
if (!root || !headline) throw new Error("Contrast fixture failed to mount");
|
||||
vi.spyOn(root, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 640, 360));
|
||||
vi.spyOn(headline, "getBoundingClientRect").mockReturnValue(new DOMRect(50, 50, 300, 40));
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation(
|
||||
() =>
|
||||
({
|
||||
display: "block",
|
||||
visibility: "visible",
|
||||
opacity: "1",
|
||||
color: "rgb(250,250,250)",
|
||||
fill: "",
|
||||
backgroundColor: "rgba(0,0,0,0)",
|
||||
backgroundImage: "none",
|
||||
fontSize: "32px",
|
||||
fontWeight: "700",
|
||||
}) as unknown as CSSStyleDeclaration,
|
||||
);
|
||||
class MockImage {
|
||||
onload: (() => void) | null = null;
|
||||
naturalWidth = 640;
|
||||
naturalHeight = 360;
|
||||
set src(_value: string) {
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("Image", MockImage);
|
||||
const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext") as unknown as {
|
||||
mockReturnValue(value: CanvasRenderingContext2D): void;
|
||||
};
|
||||
getContextSpy.mockReturnValue({
|
||||
drawImage() {},
|
||||
getImageData() {
|
||||
return { data: new Uint8ClampedArray(640 * 360 * 4).fill(255) };
|
||||
},
|
||||
} as unknown as CanvasRenderingContext2D);
|
||||
const main = {
|
||||
time: () => 5,
|
||||
repeat: () => 0,
|
||||
globalTime: (local: number) => local,
|
||||
getChildren: () => children,
|
||||
};
|
||||
const children = tweens.map((tween) => ({
|
||||
parent: main,
|
||||
targets: () => [document.querySelector(tween.target)],
|
||||
duration: () => tween.duration,
|
||||
totalDuration: () => tween.duration * ((tween.repeat ?? 0) + 1),
|
||||
repeat: () => tween.repeat ?? 0,
|
||||
globalTime: (local: number) => tween.start + local,
|
||||
startTime: () => tween.start,
|
||||
endTime: () => tween.start + tween.duration,
|
||||
timeScale: () => 1,
|
||||
paused: () => false,
|
||||
}));
|
||||
if (tweens.length > 0) {
|
||||
Reflect.set(window, "__timelines", {
|
||||
main,
|
||||
});
|
||||
}
|
||||
const page = fakePage();
|
||||
const annotations: string[] = [];
|
||||
page.screenshot = vi.fn(async () => {
|
||||
annotations.push(document.getElementById("__hyperframesCheckAnnotations")?.textContent ?? "");
|
||||
return "c3R1Yg==";
|
||||
});
|
||||
installSessionMock(page);
|
||||
try {
|
||||
const result = await runBrowserCheck(
|
||||
PROJECT,
|
||||
{ ...DEFAULT_CHECK_OPTIONS, samples: 1, contrast: true, skipContrastInMotion, snapshots },
|
||||
{ kind: "none" },
|
||||
runAuditGrid,
|
||||
);
|
||||
return { ...result, annotations };
|
||||
} finally {
|
||||
Reflect.deleteProperty(window, "__timelines");
|
||||
}
|
||||
}
|
||||
|
||||
const fadingIn: Tween[] = [{ target: "#headline", start: 4.8, duration: 0.6 }];
|
||||
|
||||
it("drops a failure measured mid-tween only when enabled", async () => {
|
||||
const off = await contrastFindings(fadingIn, undefined);
|
||||
expect(off.contrastFindings).toEqual([
|
||||
expect.objectContaining({ selector: "#headline", severity: "error" }),
|
||||
]);
|
||||
expect(off.contrastFindings[0]).not.toHaveProperty("inMotion");
|
||||
expect((await contrastFindings(fadingIn, true)).contrastFindings).toEqual([]);
|
||||
});
|
||||
|
||||
it("removes filtered failures from checked counts and snapshot annotations", async () => {
|
||||
const off = await contrastFindings(fadingIn, undefined, true);
|
||||
expect(off.contrastChecked).toBe(1);
|
||||
expect(off.annotations.some((text) => text.includes("contrast_aa_failure"))).toBe(true);
|
||||
const on = await contrastFindings(fadingIn, true, true);
|
||||
expect(on.contrastChecked).toBe(0);
|
||||
expect(on.contrastPassed).toBe(0);
|
||||
expect(on.annotations.every((text) => !text.includes("contrast_aa_failure"))).toBe(true);
|
||||
expect(on.screenshots).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("still reports the same text once its tween has ended", async () => {
|
||||
const settled: Tween[] = [{ target: "#headline", start: 1, duration: 0.6 }];
|
||||
expect((await contrastFindings(settled, true)).contrastFindings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("still reports text under only ambient tweens on an ancestor", async () => {
|
||||
const ambient: Tween[] = [
|
||||
{ target: "#scene", start: 0, duration: 10 },
|
||||
{ target: "#scene", start: 0, duration: 2, repeat: -1 },
|
||||
];
|
||||
expect((await contrastFindings(ambient, true)).contrastFindings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("still reports text on a page without GSAP", async () => {
|
||||
expect((await contrastFindings([], true)).contrastFindings).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("carries validate's clip-duration audit into the runtime findings", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(100);
|
||||
mountCanvasFixture();
|
||||
|
||||
@@ -33,6 +33,11 @@ import { ambiguousIssue, type MotionFrame } from "./motionAudit.js";
|
||||
import type { LayoutIssue, LayoutIssueCode, LayoutRect } from "./layoutAudit.js";
|
||||
import { serveStaticProjectHtml } from "./staticProjectServer.js";
|
||||
import { collectTweenRecords, findTweenTails, tweenTailMessage } from "./gsapTweenTails.js";
|
||||
import {
|
||||
measuredInMotion,
|
||||
readContrastMotion,
|
||||
type ContrastMotionSample,
|
||||
} from "./contrastMotion.js";
|
||||
import { resolveAutoProxy } from "./projectConfig.js";
|
||||
import { findStageProxyBox } from "./compositionViewport.js";
|
||||
import type { PreviewProxyBox } from "@hyperframes/core";
|
||||
@@ -125,6 +130,7 @@ interface PreparedContrast {
|
||||
candidate: ContrastCandidate;
|
||||
knownBackdrop: boolean;
|
||||
anchor: CheckAnchor;
|
||||
motion: ContrastMotionSample | null;
|
||||
}
|
||||
|
||||
interface FinishedContrast {
|
||||
@@ -919,7 +925,8 @@ export function createPageDriver(
|
||||
collectMotionFrame: (time, selectors, scopes) =>
|
||||
collectMotionFrame(page, time, selectors, scopes),
|
||||
anchorMotionIssues: (issues) => anchorLayoutIssues(page, issues),
|
||||
collectContrast: (time, annotations) => collectContrast(page, time, annotations),
|
||||
collectContrast: (time, annotations, skipInMotion) =>
|
||||
collectContrast(page, time, annotations, skipInMotion),
|
||||
collectCraftFrame: (time) => collectCraftFrame(page, time),
|
||||
collectCraftFilm: () => collectCraftFilm(page),
|
||||
});
|
||||
@@ -1504,10 +1511,13 @@ async function collectContrast(
|
||||
page: Page,
|
||||
time: number,
|
||||
snapshotAnnotations?: CheckAnnotationBox[],
|
||||
skipInMotion = false,
|
||||
): Promise<ContrastCapture> {
|
||||
let prepared: PreparedContrast[] = [];
|
||||
try {
|
||||
prepared = parsePreparedContrast(await prepareContrast(page, time));
|
||||
const candidates = await prepareContrast(page, time);
|
||||
const motion = skipInMotion ? await page.evaluate(readContrastMotion).catch(() => []) : [];
|
||||
prepared = parsePreparedContrast(candidates, motion);
|
||||
if (prepared.length === 0 && !snapshotAnnotations) return { entries: [] };
|
||||
// This screenshot is the one contrast math is sampled from below — it must
|
||||
// stay untouched by the annotation overlay (finishContrast reads real
|
||||
@@ -1767,14 +1777,25 @@ async function finishContrast(
|
||||
);
|
||||
}
|
||||
|
||||
function parsePreparedContrast(raw: unknown[]): PreparedContrast[] {
|
||||
return raw.flatMap((value) => {
|
||||
function parsePreparedContrast(
|
||||
raw: unknown[],
|
||||
motion: (ContrastMotionSample | null)[],
|
||||
): PreparedContrast[] {
|
||||
return raw.flatMap((value, index) => {
|
||||
if (!isRecord(value)) return [];
|
||||
const raw = Reflect.get(value, "candidate");
|
||||
const candidate = parseContrastCandidate(raw);
|
||||
const anchor = parseAnchor(Reflect.get(value, "anchor"));
|
||||
return candidate && anchor
|
||||
? [{ raw, candidate, anchor, knownBackdrop: knownBackdrop(raw) }]
|
||||
? [
|
||||
{
|
||||
raw,
|
||||
candidate,
|
||||
anchor,
|
||||
knownBackdrop: knownBackdrop(raw),
|
||||
motion: motion[index] ?? null,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
@@ -1829,7 +1850,9 @@ function joinContrastEntries(
|
||||
candidate.candidate.selector === entry.selector && candidate.candidate.text === entry.text,
|
||||
);
|
||||
const match = index >= 0 ? remaining.splice(index, 1)[0] : undefined;
|
||||
return match ? [{ ...entry, ...match.anchor }] : [];
|
||||
if (!match) return [];
|
||||
if (!entry.wcagAA && match.motion && measuredInMotion(match.motion)) return [];
|
||||
return [{ ...entry, ...match.anchor }];
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -603,9 +603,12 @@ async function collectContrastAt(
|
||||
collected: GridSamples,
|
||||
): Promise<void> {
|
||||
const contrastStart = Date.now();
|
||||
const capture = options.snapshots
|
||||
? await driver.collectContrast(time, annotationBoxesFrom(issuesAtTime))
|
||||
: await driver.collectContrast(time);
|
||||
const annotations = options.snapshots ? annotationBoxesFrom(issuesAtTime) : undefined;
|
||||
const capture = options.skipContrastInMotion
|
||||
? await driver.collectContrast(time, annotations, true)
|
||||
: options.snapshots
|
||||
? await driver.collectContrast(time, annotations)
|
||||
: await driver.collectContrast(time);
|
||||
collected.contrastMs += Date.now() - contrastStart;
|
||||
collected.contrastEntries.push(...capture.entries);
|
||||
if (capture.pngBase64) collected.screenshots.push({ time, pngBase64: capture.pngBase64 });
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface CheckOptions {
|
||||
tolerance: number;
|
||||
timeout: number;
|
||||
contrast: boolean;
|
||||
skipContrastInMotion?: boolean;
|
||||
strict: boolean;
|
||||
snapshots: boolean;
|
||||
captionZone?: CaptionZoneOptions;
|
||||
@@ -267,6 +268,7 @@ export interface CheckAuditDriver {
|
||||
collectContrast(
|
||||
time: number,
|
||||
snapshotAnnotations?: CheckAnnotationBox[],
|
||||
skipInMotion?: boolean,
|
||||
): Promise<ContrastCapture>;
|
||||
/** Craft section: text, stillness and paint at the current seek. A driver without it skips the section. */
|
||||
collectCraftFrame?(time: number): Promise<CraftFrame>;
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createRequire } from "node:module";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { measuredInMotion, readContrastMotion } from "./contrastMotion.js";
|
||||
|
||||
function stage(labelOpacity: number): { cam: HTMLElement; card: HTMLElement; label: HTMLElement } {
|
||||
document.body.innerHTML = `<div id="cam"><div id="card"><span id="label" style="opacity:${labelOpacity}">Revenue</span></div></div><span id="other">Other</span>`;
|
||||
const byId = (id: string) => document.getElementById(id) as HTMLElement;
|
||||
return { cam: byId("cam"), card: byId("card"), label: byId("label") };
|
||||
}
|
||||
|
||||
function prepared(...elements: Element[]): void {
|
||||
Reflect.set(
|
||||
window,
|
||||
"__contrastAuditRestores",
|
||||
elements.map((el) => ({ el })),
|
||||
);
|
||||
}
|
||||
|
||||
interface RealTimeline {
|
||||
to(target: Element | object, vars: Record<string, unknown>, position?: number): RealTimeline;
|
||||
add(child: RealTimeline, position: number): RealTimeline;
|
||||
timeScale(value: number): RealTimeline;
|
||||
totalTime(value: number, suppressEvents: boolean): RealTimeline;
|
||||
pause(): RealTimeline;
|
||||
kill(): void;
|
||||
}
|
||||
const coreRequire = createRequire(resolve(__dirname, "../../../core/package.json"));
|
||||
window.eval(readFileSync(coreRequire.resolve("gsap/dist/gsap.js"), "utf8"));
|
||||
// The evaluated UMD bundle exposes GSAP across the browser-script boundary.
|
||||
const realGsap = Reflect.get(window, "gsap") as unknown as {
|
||||
timeline(vars: Record<string, unknown>): RealTimeline;
|
||||
ticker: { sleep(): void };
|
||||
};
|
||||
|
||||
describe("real GSAP sample clocks", () => {
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, "__timelines");
|
||||
Reflect.deleteProperty(window, "__contrastAuditRestores");
|
||||
realGsap.ticker.sleep();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "scaled root", scale: 2, repeat: 0, yoyo: false, time: 1.5 },
|
||||
{ name: "root repeat", scale: 1, repeat: 1, yoyo: false, time: 5.5 },
|
||||
{ name: "root yoyo", scale: 1, repeat: 1, yoyo: true, time: 6.5 },
|
||||
])("detects motion on a $name", ({ scale, repeat, yoyo, time }) => {
|
||||
const { label } = stage(1);
|
||||
const root = realGsap.timeline({ paused: true, repeat, yoyo });
|
||||
root.to(label, { x: 100, duration: 1 }, 1).to({}, { duration: 1 }, 3);
|
||||
root.timeScale(scale).totalTime(time, true);
|
||||
Reflect.set(window, "__timelines", { root });
|
||||
prepared(label);
|
||||
try {
|
||||
const sample = readContrastMotion()[0];
|
||||
expect(sample).not.toBeNull();
|
||||
expect(sample && measuredInMotion(sample)).toBe(true);
|
||||
} finally {
|
||||
root.kill();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("contrast motion policy with real GSAP", () => {
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, "__timelines");
|
||||
Reflect.deleteProperty(window, "__contrastAuditRestores");
|
||||
realGsap.ticker.sleep();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "nested scaled tween", time: 2.75, repeat: 0, yoyo: false, expected: true },
|
||||
{ name: "nested tween settled", time: 3.5, repeat: 0, yoyo: false, expected: false },
|
||||
{ name: "nested repeat", time: 4.25, repeat: 1, yoyo: false, expected: true },
|
||||
{ name: "nested yoyo", time: 3.75, repeat: 1, yoyo: true, expected: true },
|
||||
{ name: "nested repeat delay", time: 3.25, repeat: 1, yoyo: false, expected: false },
|
||||
])("samples $name", ({ time, repeat, yoyo, expected }) => {
|
||||
const { card, label } = stage(1);
|
||||
const root = realGsap.timeline({ paused: true });
|
||||
const scene = realGsap.timeline({ repeat, yoyo, repeatDelay: 1 });
|
||||
scene.to(card, { x: 100, duration: 1 }, 1).timeScale(2);
|
||||
root.add(scene, 2).to({}, { duration: 1 }, 8).totalTime(time, true);
|
||||
Reflect.set(window, "__timelines", { root, scene });
|
||||
prepared(label);
|
||||
try {
|
||||
const sample = readContrastMotion()[0];
|
||||
expect(sample?.tweens).toHaveLength(1);
|
||||
expect(sample && measuredInMotion(sample)).toBe(expected);
|
||||
} finally {
|
||||
root.kill();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ duration: 4, repeat: 0, opacity: 1, expected: false },
|
||||
{ duration: 3.999, repeat: 0, opacity: 1, expected: true },
|
||||
{ duration: 1, repeat: -1, opacity: 1, expected: false },
|
||||
{ duration: 1, repeat: 2, opacity: 1, expected: false },
|
||||
{ duration: 4, repeat: 0, opacity: 0.6, expected: true },
|
||||
{ duration: 4, repeat: 0, opacity: 0.9996, expected: false },
|
||||
])("preserves ambient and opacity policy: %j", ({ duration, repeat, opacity, expected }) => {
|
||||
const { card, label } = stage(opacity);
|
||||
const root = realGsap.timeline({ paused: true });
|
||||
root.to(card, { x: 100, duration, repeat }).totalTime(0.5, true);
|
||||
Reflect.set(window, "__timelines", { root });
|
||||
prepared(label);
|
||||
try {
|
||||
const sample = readContrastMotion()[0];
|
||||
expect(sample && measuredInMotion(sample)).toBe(expected);
|
||||
} finally {
|
||||
root.kill();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([2.1, 5.3, 12.7])("keeps ambient motion inside an infinite timeline at %s", (time) => {
|
||||
const { card, label } = stage(1);
|
||||
const root = realGsap.timeline({ paused: true });
|
||||
const pulse = realGsap.timeline({ repeat: -1, yoyo: true });
|
||||
pulse.to(card, { scale: 1.05, duration: 0.8 }).to(card, { scale: 1, duration: 0.8 });
|
||||
root.add(pulse, 0).totalTime(time, true);
|
||||
Reflect.set(window, "__timelines", { root });
|
||||
prepared(label);
|
||||
try {
|
||||
const sample = readContrastMotion()[0];
|
||||
expect(sample && measuredInMotion(sample)).toBe(false);
|
||||
} finally {
|
||||
root.kill();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not treat a paused nested timeline as moving", () => {
|
||||
const { label } = stage(1);
|
||||
const root = realGsap.timeline({ paused: true });
|
||||
const scene = realGsap.timeline({});
|
||||
scene.to(label, { x: 100, duration: 1 });
|
||||
root.add(scene, 0).to({}, { duration: 1 }, 3).totalTime(0.5, true);
|
||||
scene.pause();
|
||||
Reflect.set(window, "__timelines", { root });
|
||||
prepared(label);
|
||||
const sample = readContrastMotion()[0];
|
||||
expect(sample && measuredInMotion(sample)).toBe(false);
|
||||
root.kill();
|
||||
});
|
||||
|
||||
it("keeps untargeted partial-opacity text and no-GSAP pages", () => {
|
||||
const { card, label } = stage(0.5);
|
||||
prepared(label);
|
||||
expect(readContrastMotion()).toEqual([null]);
|
||||
const root = realGsap.timeline({ paused: true });
|
||||
root.to({}, { duration: 1 }).totalTime(0.5, true);
|
||||
Reflect.set(window, "__timelines", { root });
|
||||
expect(readContrastMotion()).toEqual([null]);
|
||||
card.style.opacity = "0.8";
|
||||
root.to(label, { x: 10, duration: 1 }, 1).totalTime(3, true);
|
||||
const sample = readContrastMotion()[0];
|
||||
expect(sample?.opacity).toBeCloseTo(0.4);
|
||||
expect(sample && measuredInMotion(sample)).toBe(true);
|
||||
root.kill();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
export interface ContrastTweenState {
|
||||
active: boolean;
|
||||
infiniteAncestor: boolean;
|
||||
duration: number;
|
||||
repeat: number;
|
||||
}
|
||||
|
||||
export interface ContrastMotionSample {
|
||||
opacity: number;
|
||||
tweens: ContrastTweenState[];
|
||||
}
|
||||
|
||||
/** A repeating tween or one this long is set dressing (a camera drift, a pulse), not an entrance or exit. */
|
||||
const AMBIENT_TWEEN_SECONDS = 4;
|
||||
|
||||
function isAmbientTween(tween: ContrastTweenState): boolean {
|
||||
return tween.infiniteAncestor || tween.repeat !== 0 || tween.duration >= AMBIENT_TWEEN_SECONDS;
|
||||
}
|
||||
|
||||
/** True when a non-ambient tween on the text or an ancestor is running at the current seek, or the text is not fully opaque. */
|
||||
export function measuredInMotion(sample: ContrastMotionSample): boolean {
|
||||
if (Math.round(sample.opacity * 1000) < 1000) return true;
|
||||
return sample.tweens.some((tween) => !isAmbientTween(tween) && tween.active);
|
||||
}
|
||||
|
||||
// Serialized into the page: read prepared elements at the current seek without advancing GSAP.
|
||||
// fallow-ignore-next-line complexity
|
||||
export function readContrastMotion(): (ContrastMotionSample | null)[] {
|
||||
type Animation = {
|
||||
parent: Animation | null;
|
||||
targets: () => unknown[];
|
||||
duration: () => number;
|
||||
totalDuration: () => number;
|
||||
repeat: () => number;
|
||||
time: () => number;
|
||||
startTime: () => number;
|
||||
endTime: () => number;
|
||||
timeScale: () => number;
|
||||
paused: () => boolean;
|
||||
getChildren: (nested: boolean, tweens: boolean, timelines: boolean) => Animation[];
|
||||
};
|
||||
const restores: unknown = Reflect.get(window, "__contrastAuditRestores");
|
||||
const registry: unknown = Reflect.get(window, "__timelines");
|
||||
const timelines = Object.values(typeof registry === "object" && registry ? registry : {}).filter(
|
||||
(timeline): timeline is Animation =>
|
||||
typeof timeline?.getChildren === "function" && typeof timeline.time === "function",
|
||||
);
|
||||
const registered = new Set<Animation>(timelines);
|
||||
const byTarget = new Map<Element, ContrastTweenState[]>();
|
||||
|
||||
function nestedInAnother(timeline: Animation): boolean {
|
||||
for (let parent = timeline.parent; parent; parent = parent.parent) {
|
||||
if (registered.has(parent)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function activeInRoot(animation: Animation, root: Animation): boolean {
|
||||
if (animation === root) return true;
|
||||
const parent = animation.parent;
|
||||
if (!parent || animation.paused() || animation.timeScale() === 0) return false;
|
||||
const time = parent.time();
|
||||
return (
|
||||
animation.startTime() <= time + 1e-6 &&
|
||||
time < animation.endTime() - 1e-6 &&
|
||||
activeInRoot(parent, root)
|
||||
);
|
||||
}
|
||||
|
||||
function hasInfiniteAncestor(animation: Animation, root: Animation): boolean {
|
||||
for (let parent = animation.parent; parent; parent = parent.parent) {
|
||||
if (parent.repeat() === -1) return true;
|
||||
if (parent === root) break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function addTweens(timeline: Animation): void {
|
||||
for (const tween of timeline.getChildren(true, true, false)) {
|
||||
const total = tween.totalDuration();
|
||||
if (!(total > 0)) continue;
|
||||
const state = {
|
||||
active: activeInRoot(tween, timeline),
|
||||
infiniteAncestor: hasInfiniteAncestor(tween, timeline),
|
||||
duration: tween.duration(),
|
||||
repeat: tween.repeat(),
|
||||
};
|
||||
for (const target of tween.targets()) {
|
||||
if (target instanceof Element)
|
||||
byTarget.set(target, [...(byTarget.get(target) ?? []), state]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sampleOf(element: unknown): ContrastMotionSample | null {
|
||||
if (!(element instanceof Element)) return null;
|
||||
const tweens: ContrastTweenState[] = [];
|
||||
let opacity = 1;
|
||||
for (let node: Element | null = element; node; node = node.parentElement) {
|
||||
tweens.push(...(byTarget.get(node) ?? []));
|
||||
const own = Number.parseFloat(getComputedStyle(node).opacity);
|
||||
if (Number.isFinite(own)) opacity *= own;
|
||||
}
|
||||
return tweens.length > 0 ? { opacity, tweens } : null;
|
||||
}
|
||||
|
||||
for (const timeline of timelines) {
|
||||
try {
|
||||
if (!nestedInAnother(timeline)) addTweens(timeline);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return (Array.isArray(restores) ? restores : []).map((restore) =>
|
||||
typeof restore === "object" && restore ? sampleOf(Reflect.get(restore, "el")) : null,
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
"files": 77
|
||||
},
|
||||
"hyperframes-cli": {
|
||||
"hash": "08a4a3a815d71988",
|
||||
"hash": "70516c62caf45a8a",
|
||||
"files": 13
|
||||
},
|
||||
"hyperframes-registry": {
|
||||
|
||||
@@ -38,6 +38,7 @@ npx hyperframes check --at-transitions # also sample every tween start/end bou
|
||||
npx hyperframes check --tolerance 4 # allowed overflow px before reporting (default 2)
|
||||
npx hyperframes check --timeout 30000 # initial render-ready + navigation minimum in ms (defaults: 3000 / 10000)
|
||||
npx hyperframes check --no-contrast # skip the WCAG audit while iterating
|
||||
npx hyperframes check --skip-contrast-in-motion # leave out contrast failures measured mid-animation (or HYPERFRAMES_CHECK_SKIP_CONTRAST_IN_MOTION=1)
|
||||
npx hyperframes check --strict # exit non-zero on warnings too (default: only errors)
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user