mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(cli): check warns instead of failing on a still title card (#5369)
* fix(cli): check passes a still title card whose timeline follows the seek * fix(cli): check warns on a still frame and fails only when an animation is stuck * fix(cli): count a seek clock once per sample and point the error at the registered timeline * fix(cli): judge a finished animation by its own completion state, not its end time * test(cli): pin a Web Animation paused at its end as finished * fix(cli): judge a reversed animation's end in its own direction, and read plain timelines * test(cli): pin a plain timeline held at its end as finished * test(cli): keep an unfinished plain timeline beside the finished one * fix(cli): skip a timeline whose time cannot be read instead of calling it stuck * fix(cli): skip an animation whose time is not a plain number, such as a scroll-driven one * fix(cli): a partial timeline with plain values no longer stops the browser checks
This commit is contained in:
@@ -713,11 +713,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 */}
|
||||
|
||||
@@ -771,7 +774,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
|
||||
|
||||
@@ -51,6 +51,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 = {
|
||||
@@ -166,6 +167,7 @@ function fakeDriver(overrides: Partial<CheckAuditDriver> = {}): 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 () => []),
|
||||
@@ -1577,11 +1579,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({
|
||||
|
||||
@@ -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 = `<body>
|
||||
<style>
|
||||
@keyframes slide { to { transform: translateX(100px) } }
|
||||
#forever { animation: slide 1s linear infinite paused }
|
||||
#scrolled { animation: slide linear both; animation-timeline: scroll(root) }
|
||||
#viewed { animation: slide linear both; animation-timeline: view() }
|
||||
</style>
|
||||
<div id="forever"></div>
|
||||
<div id="scrolled"></div>
|
||||
<div id="viewed"></div>
|
||||
<div id="reverse"></div>
|
||||
<div id="held"></div>
|
||||
<div id="parked"></div>
|
||||
<div id="rewound"></div>
|
||||
<script>
|
||||
const clock = (total, at, reversed = false) => {
|
||||
let now = at;
|
||||
return {
|
||||
totalDuration: () => total,
|
||||
totalTime: () => now,
|
||||
totalProgress: () => now / total,
|
||||
reversed: () => reversed,
|
||||
seek: (t) => (now = t),
|
||||
};
|
||||
};
|
||||
window.registered = clock(4, 0);
|
||||
window.__timelines = { main: window.registered, empty: clock(0, 0) };
|
||||
// A yoyo that finished: its one-iteration time() is back at 0, its totalTime() is at the end.
|
||||
const finishedYoyo = clock(0.2, 0.2);
|
||||
const finishedReversed = clock(4, 0, true);
|
||||
window.gsap = { globalTimeline: { getChildren: () => [finishedYoyo, finishedReversed] } };
|
||||
const reverse = document.getElementById("reverse").animate(
|
||||
[{ opacity: 0 }, { opacity: 1 }],
|
||||
{ duration: 9000, fill: "both" },
|
||||
);
|
||||
reverse.playbackRate = -1;
|
||||
reverse.finish();
|
||||
// Paused at its end, the way a seek leaves it: never "finished", still done.
|
||||
const held = document.getElementById("held").animate([{ opacity: 0 }, { opacity: 1 }], { duration: 3000, fill: "forwards" });
|
||||
held.pause();
|
||||
held.currentTime = 3000;
|
||||
// Reversed and paused: at its end it has not started, at 0 it is complete.
|
||||
const backwards = (id, at) => {
|
||||
const animation = document.getElementById(id).animate([{ opacity: 0 }, { opacity: 1 }], { duration: 3000, fill: "both" });
|
||||
animation.playbackRate = -1;
|
||||
animation.pause();
|
||||
animation.currentTime = at;
|
||||
};
|
||||
backwards("parked", 3000);
|
||||
backwards("rewound", 0);
|
||||
// A registered timeline with only the one-iteration methods, which the runtime accepts.
|
||||
window.__timelines.partial = { duration: () => 4, time: () => 1 };
|
||||
window.__timelines.partialEnded = { duration: () => 4, time: () => 4 };
|
||||
// Seekable but with no way to read its time: not evidence either way.
|
||||
window.__timelines.seekOnly = { duration: () => 4, seek: () => {} };
|
||||
// Plain values where GSAP has methods must not stop the read.
|
||||
window.__timelines.plainDuration = { duration: 4, seek: () => {} };
|
||||
window.__timelines.plainReversed = { totalDuration: () => 4, totalTime: () => 4, reversed: false };
|
||||
</script>
|
||||
</body>`;
|
||||
|
||||
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("<body><h1>Hello</h1></body>");
|
||||
|
||||
expect(await collectSeekClock(page)).toEqual([]);
|
||||
await page.close();
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function collectSeekClock(page: Page): Promise<SeekClock[]> {
|
||||
// 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<object, number>; 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. */
|
||||
|
||||
@@ -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<number, SeekClock[]>();
|
||||
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(),
|
||||
);
|
||||
|
||||
@@ -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<string>;
|
||||
collectSeekClock(): Promise<SeekClock[]>;
|
||||
/** 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<RotationSample[]>;
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"files": 7
|
||||
},
|
||||
"hyperframes-cli": {
|
||||
"hash": "c053e99be2d209b1",
|
||||
"hash": "5076477812cf2c37",
|
||||
"files": 11
|
||||
},
|
||||
"hyperframes-core": {
|
||||
|
||||
@@ -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 `<canvas>` 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 `<canvas>` 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):
|
||||
|
||||
|
||||
Reference in New Issue
Block a user