diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index b9a3f305691..9cf2db06af4 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -712,11 +712,14 @@ background colours, the measured ratio against the required one, and a compliant colour you could use instead. Severity is persistence-aware: a finding at a single sample demotes to info, a -finding that persists gates the exit code, and a timeline that never moves on a -composition of 3s or more fails with `sweep_static`. When only audio advanced -and nothing on screen moved, `sweep_static` is a warning instead (`--strict` -fails the run on it); add `data-no-timeline` to a composition that is meant to -be still. +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 +tween, or a CSS or Web animation, on the page held the same time without ever +finishing, so the seek never drove it. +Any other frame that never changes, such as a still title card, is a +`sweep_static` warning ("Nothing on screen moved under seek"), and so is an +audio-only advance; `--strict` fails the run on either. A composition that is +meant to be still can declare `data-no-timeline` to skip the guard. {/* VISUAL: a `check --snapshots` overview frame beside one finding crop, with the labelled finding box visible on both */} @@ -770,7 +773,7 @@ borders and shadows, the pixels of visible canvas/video/img elements, and video playback time all count as motion — so a playing background video keeps a scope live on its own, even a color-graded one whose picture is drawn into a canvas. Audio playback time never counts here: it shows the timeline ran, but it is not -a moving picture. The frozen-sweep guard turns an audio-only advance into a +a moving picture. The frozen-sweep guard reports an audio-only advance as a warning rather than a failure. Elements under `data-layout-ignore` inside the scope never count; the opt-out does not apply to the scope element itself, since naming it in an assertion outranks it. Content diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index 74151041335..524caf14b2f 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -40,6 +40,7 @@ import type { LayoutOverflow, LayoutRect, } from "../utils/layoutAudit.js"; +import type { SeekClock } from "../utils/checkTypes.js"; import type { ProjectDir } from "../utils/project.js"; const PROJECT: ProjectDir = { @@ -155,6 +156,7 @@ function fakeDriver(overrides: Partial = {}): CheckAuditDriver collectLayout: vi.fn(async (_time: number, _tolerance: number) => []), collectOverlap: vi.fn(async (_time: number) => []), collectLayoutGeometry: vi.fn(async () => `geometry-${geometryCallCount++}`), + collectSeekClock: vi.fn(async () => [{ id: 1, time: 0, done: false }]), collectRotationSample: vi.fn(async (_time: number) => []), collectOffPivotRotationSample: vi.fn(async (time: number) => ({ time, samples: [] })), collectGeometryCandidates: vi.fn(async () => []), @@ -1528,11 +1530,72 @@ describe("check pipeline", () => { finding.code === "sweep_static" && finding.severity === "error" && finding.message.includes("did not advance") && - finding.fixHint?.includes("data-no-timeline"), + finding.fixHint?.includes("window.__timelines"), ), ).toBe(true); }); + function stillCard(clocks: (sample: number) => SeekClock[]) { + let sample = 0; + return fakeDriver({ + getDuration: vi.fn(async () => 4), + collectLayoutGeometry: vi.fn(async () => "frozen"), + collectSeekClock: vi.fn(async () => clocks(sample++)), + }); + } + + function sweepOf(report: CheckReport): [string, string][] { + return report.layout.findings + .filter((finding) => finding.code === "sweep_static") + .map((finding) => [finding.severity, finding.message]); + } + + it.each([ + [ + "whose timeline follows the seek", + (sample: number) => [{ id: 1, time: sample, done: false }], + ], + ["with nothing that could move", () => []], + ["whose only animation already holds at its end", () => [{ id: 1, time: 4, done: true }]], + [ + "whose timeline is seen, listed twice, at only one sample", + (sample: number) => + sample === 2 + ? [ + { id: 1, time: 0, done: false }, + { id: 1, time: 0, done: false }, + ] + : [], + ], + ])("warns, without failing, on a still card %s", async (_case, clocks) => { + const { report } = await runScenario(stillCard(clocks)); + + expect(sweepOf(report)).toEqual([["warning", "Nothing on screen moved under seek."]]); + expect(report.ok).toBe(true); + }); + + it.each([ + [ + "one animation is stuck while another follows the seek", + (sample: number) => [ + { id: 1, time: 0, done: false }, + { id: 2, time: sample, done: false }, + ], + ], + [ + "a tween created mid-run is stuck", + (sample: number) => [ + { id: 1, time: sample, done: false }, + ...(sample >= 2 ? [{ id: 2, time: 0, done: false }] : []), + ], + ], + ])("fails when %s", async (_case, clocks) => { + const { report } = await runScenario(stillCard(clocks)); + + expect(sweepOf(report).map(([severity]) => severity)).toEqual(["error"]); + expect(report.ok).toBe(false); + }); + it("warns, without failing, when only the audio advanced and nothing on screen moved", async () => { let call = 0; const driver = fakeDriver({ diff --git a/packages/cli/src/utils/checkBrowser.chromium.test.ts b/packages/cli/src/utils/checkBrowser.chromium.test.ts new file mode 100644 index 00000000000..1410fb97794 --- /dev/null +++ b/packages/cli/src/utils/checkBrowser.chromium.test.ts @@ -0,0 +1,126 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import puppeteer, { type Browser, type Page } from "puppeteer-core"; +import { collectSeekClock } from "./checkBrowser.js"; + +const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH; + +// Objects with GSAP's total*() and reversed() shape; GSAP itself is not a repo dependency. +const PAGE = ` + +
+
+
+
+
+
+
+ +`; + +describe.runIf(executablePath)("collectSeekClock in Chromium", () => { + let browser: Browser; + let page: Page; + beforeAll(async () => { + browser = await puppeteer.launch({ executablePath, args: ["--no-sandbox"] }); + }); + afterAll(async () => { + await browser?.close(); + }); + + async function seekTo(time: number) { + await page.evaluate((t) => { + Reflect.get(window, "registered").seek(t); + const forever = document.getElementById("forever")?.getAnimations()[0]; + if (forever) forever.currentTime = t * 1000; + }, time); + return collectSeekClock(page); + } + + it("marks finished clocks done, an infinite one never, and keeps ids across samples", async () => { + page = await browser.newPage(); + await page.setContent(PAGE); + + const first = await seekTo(1); + await page.evaluate(() => { + const late = { totalDuration: () => 2, totalTime: () => 0, totalProgress: () => 0 }; + Reflect.set(window, "__timelines", { late, ...Reflect.get(window, "__timelines") }); + }); + const second = await seekTo(2); + + expect(first.map(({ time, done }) => [time, done])).toEqual([ + [1, false], + [1, false], + [4, true], + [4, true], + [0.2, true], + [0, true], + [1000, false], + [0, true], + [3000, true], + [3000, false], + [0, true], + ]); + const [late, ...rest] = second; + expect(rest.map(({ id }) => id)).toEqual(first.map(({ id }) => id)); + expect(first.map(({ id }) => id)).not.toContain(late?.id); + expect(second.map(({ time }) => time)).toEqual([0, 2, 1, 4, 4, 0.2, 0, 2000, 0, 3000, 3000, 0]); + await page.close(); + }); + + it("reports nothing on a page with no animations", async () => { + page = await browser.newPage(); + await page.setContent("

Hello

"); + + expect(await collectSeekClock(page)).toEqual([]); + await page.close(); + }); +}); diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index 092448829ed..043734386f7 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -52,6 +52,7 @@ import type { OffPivotRotationSample, RotationSample, RunAuditGrid, + SeekClock, } from "./checkTypes.js"; import type { ProjectDir } from "./project.js"; @@ -461,6 +462,7 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud collectLayout: (time, tolerance, layout) => collectLayout(page, time, tolerance, layout), collectOverlap: (time) => collectOverlap(page, time), collectLayoutGeometry: () => collectLayoutGeometry(page), + collectSeekClock: () => collectSeekClock(page), collectRotationSample: (time) => collectRotationSample(page, time), collectOffPivotRotationSample: (time) => collectOffPivotRotationSample(page, time), collectGeometryCandidates: (time, request) => collectGeometryCandidates(page, time, request), @@ -612,6 +614,58 @@ async function collectLayoutGeometry(page: Page): Promise { }); } +export async function collectSeekClock(page: Page): Promise { + // Serialized into the page; each optional GSAP read is one branch of one function. + // fallow-ignore-next-line complexity + return page.evaluate(() => { + const callOrUndefined = (target: unknown, key: string, args: unknown[] = []): unknown => { + try { + return Reflect.apply(Reflect.get(Object(target), key), target, args); + } catch { + return undefined; + } + }; + const known: { ids: WeakMap; next: number } = Reflect.get( + window, + "__hfSeekClocks", + ) ?? { + ids: new WeakMap(), + next: 0, + }; + Reflect.set(window, "__hfSeekClocks", known); + const idOf = (target: object): number => { + if (!known.ids.has(target)) known.ids.set(target, ++known.next); + return known.ids.get(target) ?? 0; + }; + const clocks: SeekClock[] = []; + const timelines: unknown = Reflect.get(window, "__timelines"); + const registered = typeof timelines === "object" && timelines ? Object.values(timelines) : []; + const gsapRoot = Reflect.get(Reflect.get(window, "gsap") ?? {}, "globalTimeline"); + const children = callOrUndefined(gsapRoot, "getChildren", [false, true, true]); + for (const timeline of [...registered, ...(Array.isArray(children) ? children : [])]) { + const total = Number( + callOrUndefined(timeline, "totalDuration") ?? callOrUndefined(timeline, "duration"), + ); + if (typeof timeline !== "object" || !timeline || !(total > 0)) continue; + const time = Number( + callOrUndefined(timeline, "totalTime") ?? callOrUndefined(timeline, "time"), + ); + if (!Number.isFinite(time)) continue; + const progress = Number(callOrUndefined(timeline, "totalProgress") ?? time / total); + const done = callOrUndefined(timeline, "reversed") === true ? progress <= 0 : progress >= 1; + clocks.push({ id: idOf(timeline), time, done }); + } + for (const animation of document.getAnimations?.() ?? []) { + if (typeof animation.currentTime !== "number") continue; + const time = animation.currentTime; + const end = Number(animation.effect?.getComputedTiming().endTime ?? Number.POSITIVE_INFINITY); + const atEnd = animation.playbackRate < 0 ? time <= 0 : time >= end; + clocks.push({ id: idOf(animation), time, done: animation.playState === "finished" || atEnd }); + } + return clocks; + }); +} + /** Invoke a `window.__hyperframes*` sampler injected by layout-audit.browser.js * and return its array result (or [] when absent / non-array). Shared by the * per-frame sample collectors so the page.evaluate boilerplate lives once. */ diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index 6e3585d5572..4ad2fef6a90 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -56,6 +56,7 @@ import type { OffPivotFrame, OffPivotRotationSample, RotationSample, + SeekClock, } from "./checkTypes.js"; export type { @@ -217,8 +218,8 @@ interface GridSamples { contrastEntries: ContrastAuditEntry[]; screenshots: CheckScreenshot[]; contrastMs: number; - /** One visible-state fingerprint per layout sample (#U10 frozen-sweep guard). */ - layoutStateSignatures: { time: number; signature: string }[]; + /** One visible-state fingerprint and seek clock per layout sample (#U10 frozen-sweep guard). */ + layoutStateSignatures: { time: number; signature: string; clock: SeekClock[] }[]; /** Every rotatable element's geometry at each layout sample; grouped by * selector after the run to detect rotation_pivot_drift. */ rotationSamples: RotationSample[]; @@ -404,6 +405,7 @@ async function collectGridSamples( collected.layoutStateSignatures.push({ time, signature: await driver.collectLayoutGeometry(), + clock: await driver.collectSeekClock(), }); collected.rotationSamples.push(...(await driver.collectRotationSample(time))); collected.indicatorFrames.push(await driver.collectOffPivotRotationSample(time)); @@ -486,23 +488,41 @@ const ZERO_LAYOUT_RECT: LayoutRect = { * verdict from this run is meaningless, not just a missed defect. Skips * short (<3s) compositions, single-sample runs (nothing to compare), and * runs where a `motion_frozen` finding already reported the same underlying - * symptom (no double-reporting the one thing that's wrong). + * symptom (no double-reporting the one thing that's wrong). A frame that never + * changes is an error only when an animation it can see is stuck and never finished. */ function detectSweepStatic( duration: number, - layoutStateSignatures: string[], + samples: { signature: string; clock: SeekClock[] }[], motionIssues: AnchoredLayoutIssue[], hasNoTimelineDeclaration: boolean, ): AnchoredLayoutIssue[] { if (hasNoTimelineDeclaration) return []; if (duration < SWEEP_STATIC_MIN_DURATION_SEC) return []; - if (layoutStateSignatures.length < 2) return []; + if (samples.length < 2) return []; if (motionIssues.some((issue) => issue.code === "motion_frozen")) return []; - if (allSame(layoutStateSignatures)) return [sweepStaticIssue("error")]; - if (allSame(layoutStateSignatures.map(seenPart))) return [sweepStaticIssue("warning")]; + const signatures = samples.map((sample) => sample.signature); + if (allSame(signatures)) + return [sweepStaticIssue(anAnimationIsStuck(samples) ? "error" : "still")]; + if (allSame(signatures.map(seenPart))) return [sweepStaticIssue("audio")]; return []; } +function anAnimationIsStuck(samples: { clock: SeekClock[] }[]): boolean { + const seenById = new Map(); + for (const sample of samples) { + for (const clock of new Map(sample.clock.map((clock) => [clock.id, clock])).values()) { + seenById.set(clock.id, [...(seenById.get(clock.id) ?? []), clock]); + } + } + return [...seenById.values()].some( + (seen) => + seen.length > 1 && + allSame(seen.map((clock) => clock.time)) && + seen.every((clock) => !clock.done), + ); +} + // motion-signature.browser.js appends audio time after this; a signature without it is all "seen". const AUDIO_TIME_SEPARATOR = "\u001f"; @@ -510,26 +530,34 @@ function seenPart(signature: string): string { return signature.split(AUDIO_TIME_SEPARATOR)[0] ?? signature; } -function allSame(values: string[]): boolean { +function allSame(values: readonly unknown[]): boolean { return values.every((value) => value === values[0]); } -function sweepStaticIssue(severity: "error" | "warning"): AnchoredLayoutIssue { +const STILL_FIX_HINT = + "If the composition is meant to be still, add `data-no-timeline` to the element with `data-composition-id`. Otherwise confirm it seeks a paused GSAP/CSS timeline under `data-*` timing attributes rather than only autoplaying."; + +const SWEEP_STATIC_MESSAGES = { + error: "Timeline did not advance under seek; every green verdict on this run is unreliable.", + still: "Nothing on screen moved under seek.", + audio: "Only the audio advanced under seek; nothing on screen moved.", +}; + +function sweepStaticIssue(kind: keyof typeof SWEEP_STATIC_MESSAGES): AnchoredLayoutIssue { return { code: "sweep_static", - severity, + severity: kind === "error" ? "error" : "warning", time: 0, selector: "[data-composition-id]", dataAttributes: {}, sourceFile: "index.html", bbox: ZERO_BBOX, rect: ZERO_LAYOUT_RECT, - message: - severity === "error" - ? "Timeline did not advance under seek; every green verdict on this run is unreliable." - : "Only the audio advanced under seek; nothing on screen moved.", + message: SWEEP_STATIC_MESSAGES[kind], fixHint: - "If the composition is meant to be still, add `data-no-timeline` to the element with `data-composition-id`. Otherwise confirm it seeks a paused GSAP/CSS timeline under `data-*` timing attributes rather than only autoplaying.", + kind === "error" + ? "An animation on the page never moved under seek. Build it on the paused GSAP timeline registered in `window.__timelines[compositionId]`, or as a CSS animation, so the seek drives it." + : STILL_FIX_HINT, }; } @@ -1087,9 +1115,7 @@ export async function runAuditGrid( const userPicked = new Set(grid.userPickedSamples); const sweepFindings = detectSweepStatic( grid.duration, - collected.layoutStateSignatures - .filter((sample) => !userPicked.has(sample.time)) - .map((sample) => sample.signature), + collected.layoutStateSignatures.filter((sample) => !userPicked.has(sample.time)), motionIssues, await driver.hasNoTimelineDeclaration(), ); diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts index 394fb2f12dc..71957b80f07 100644 --- a/packages/cli/src/utils/checkTypes.ts +++ b/packages/cli/src/utils/checkTypes.ts @@ -50,6 +50,12 @@ export interface LayoutOptions { export type CheckSeverity = "error" | "warning" | "info"; +export interface SeekClock { + id: number; + time: number; + done: boolean; +} + export interface CheckBbox { x: number; y: number; @@ -199,6 +205,7 @@ export interface CheckAuditDriver { /** Frozen-sweep guard (#U10): an opaque fingerprint of the seeked visual state, from * motion-signature.browser.js (shared with motion-sample's liveness). Legacy name kept. */ collectLayoutGeometry(): Promise; + collectSeekClock(): Promise; /** rotation_pivot_drift: every rotatable element's bbox center/size/angle at * the current seeked state. Accumulated across the grid — see checkPipeline. */ collectRotationSample(time: number): Promise; diff --git a/skills-manifest.json b/skills-manifest.json index 49ddfb2a1e9..2a7e01cf62b 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -30,7 +30,7 @@ "files": 7 }, "hyperframes-cli": { - "hash": "c053e99be2d209b1", + "hash": "5076477812cf2c37", "files": 11 }, "hyperframes-core": { diff --git a/skills/hyperframes-cli/references/lint-validate-inspect.md b/skills/hyperframes-cli/references/lint-validate-inspect.md index 07d36146444..55b54def91a 100644 --- a/skills/hyperframes-cli/references/lint-validate-inspect.md +++ b/skills/hyperframes-cli/references/lint-validate-inspect.md @@ -50,7 +50,7 @@ One command, one Chrome boot. `check` runs the linter first and skips the browse Every finding carries a selector, the element's `data-*` identity, the composition source file, a bbox, and the sample time: jump straight from the JSON to the HTML you must edit and re-run. -**Severity is persistence-aware.** A dynamic issue observed at a single grid sample (an entrance/exit transient) demotes to info and never gates. Issues held across samples gate the exit code, a held `content_overlap` is an error, and a held, partially-visible `canvas_overflow` breaching ≥5% of the canvas promotes to warning. Coordinate-frame findings (`escaped_container`, `panel_out_of_canvas`, `connector_detached`) flag geometry computed in one frame but rendered in another — an element far outside its offset parent, a painted panel stuck across the canvas edge, a connector line detached from every node. Text drawn into a `` has no DOM box, so `canvas_overflow` cannot see it; `canvas_content_at_edge` warns when a canvas's pixels show sharp content (drawn text, hard shapes) along the frame edge — mark intentional full-bleed art (particles, photos) with `data-layout-allow-overflow`. If a 3s+ composition shows no visible change across every sample, `check` fails with `sweep_static`: a frozen timeline makes every green verdict unreliable, so it refuses to pass. When only audio advanced, it warns instead; a composition meant to be still takes `data-no-timeline` on its root. The fingerprint includes per-element opacity, so opacity-only reveals (code typing, staggered fades) count as motion — but only while they're still in flight at the sampled times. The classic trap is a reveal that completes early and then holds a static frame for the rest of the duration: every sample lands on the settled state and the run fails. Spread the reveal across the timeline or keep one continuously animated element alive (a blinking caret is idiomatic for code typing) — don't bolt on a slow position drift just to appease the check. +**Severity is persistence-aware.** A dynamic issue observed at a single grid sample (an entrance/exit transient) demotes to info and never gates. Issues held across samples gate the exit code, a held `content_overlap` is an error, and a held, partially-visible `canvas_overflow` breaching ≥5% of the canvas promotes to warning. Coordinate-frame findings (`escaped_container`, `panel_out_of_canvas`, `connector_detached`) flag geometry computed in one frame but rendered in another — an element far outside its offset parent, a painted panel stuck across the canvas edge, a connector line detached from every node. Text drawn into a `` has no DOM box, so `canvas_overflow` cannot see it; `canvas_content_at_edge` warns when a canvas's pixels show sharp content (drawn text, hard shapes) along the frame edge — mark intentional full-bleed art (particles, photos) with `data-layout-allow-overflow`. If a 3s+ composition shows no visible change across every sample, `check` fails with `sweep_static`: a frame that never changes while a GSAP timeline or tween, or a CSS or Web animation, holds the same time without ever finishing means the seek is not driving the page, so it refuses to pass. Any other frame that never changes (a still title card, an audio-only advance) is a warning that `--strict` fails; a composition meant to be still can take `data-no-timeline` on its root to skip it. The fingerprint includes per-element opacity, so opacity-only reveals (code typing, staggered fades) count as motion — but only while they're still in flight at the sampled times. The classic trap is a reveal that completes early and then holds a static frame for the rest of the duration: every sample lands on the settled state and check warns that nothing on screen moved (`--strict` fails it). Spread the reveal across the timeline or keep one continuously animated element alive (a blinking caret is idiomatic for code typing) — don't bolt on a slow position drift just to appease the check. **Escape hatches** (mark intent in the HTML, then re-run):