diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index bec5c2ee56..cfbd68401a 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -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 ""` | 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 diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index babe4df301..c5d9f3970c 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -432,6 +432,56 @@ describe("check --prepare-media", () => { }); }); +describe("skip contrast in motion", () => { + async function parsedOptions(rawArgs: string[]): Promise { + 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) => ({ diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index e2b8f6acdf..3f72c00b3c 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -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): 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"]), diff --git a/packages/cli/src/utils/checkBrowser.chromium.test.ts b/packages/cli/src/utils/checkBrowser.chromium.test.ts index 1410fb9779..b218ea1648 100644 --- a/packages/cli/src/utils/checkBrowser.chromium.test.ts +++ b/packages/cli/src/utils/checkBrowser.chromium.test.ts @@ -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('
Text
'); + 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(); + } + }); }); diff --git a/packages/cli/src/utils/checkBrowser.test.ts b/packages/cli/src/utils/checkBrowser.test.ts index a6be92d9b1..c392afce5e 100644 --- a/packages/cli/src/utils/checkBrowser.test.ts +++ b/packages/cli/src/utils/checkBrowser.test.ts @@ -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(`
Low contrast copy
`); + 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(); diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index 0e53f4ba31..c082f6559b 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -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 { 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 }]; }); } diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index c4e6ecdf2b..cf14cec3b0 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -603,9 +603,12 @@ async function collectContrastAt( collected: GridSamples, ): Promise { 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 }); diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts index 3b65ce07c6..5b5b0309b0 100644 --- a/packages/cli/src/utils/checkTypes.ts +++ b/packages/cli/src/utils/checkTypes.ts @@ -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; /** Craft section: text, stillness and paint at the current seek. A driver without it skips the section. */ collectCraftFrame?(time: number): Promise; diff --git a/packages/cli/src/utils/contrastMotion.test.ts b/packages/cli/src/utils/contrastMotion.test.ts new file mode 100644 index 0000000000..4dfc8521a1 --- /dev/null +++ b/packages/cli/src/utils/contrastMotion.test.ts @@ -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 = `
Revenue
Other`; + 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, 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): 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(); + }); +}); diff --git a/packages/cli/src/utils/contrastMotion.ts b/packages/cli/src/utils/contrastMotion.ts new file mode 100644 index 0000000000..4ebc54fe05 --- /dev/null +++ b/packages/cli/src/utils/contrastMotion.ts @@ -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(timelines); + const byTarget = new Map(); + + 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, + ); +} diff --git a/skills-manifest.json b/skills-manifest.json index 588f430c33..7d25c10648 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -10,7 +10,7 @@ "files": 77 }, "hyperframes-cli": { - "hash": "08a4a3a815d71988", + "hash": "70516c62caf45a8a", "files": 13 }, "hyperframes-registry": { diff --git a/skills/hyperframes-cli/references/lint-validate-inspect.md b/skills/hyperframes-cli/references/lint-validate-inspect.md index 784e3860fb..b3f60b8f1e 100644 --- a/skills/hyperframes-cli/references/lint-validate-inspect.md +++ b/skills/hyperframes-cli/references/lint-validate-inspect.md @@ -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) ```