diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx
index 6e19367f923..d69e1638f74 100644
--- a/docs/packages/cli.mdx
+++ b/docs/packages/cli.mdx
@@ -704,7 +704,10 @@ 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`.
+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.
{/* VISUAL: a `check --snapshots` overview frame beside one finding crop, with the labelled finding box visible on both */}
@@ -753,13 +756,19 @@ HTML. Without a sidecar, nothing changes.
`keepsMoving` uses the same motion classifier as the frozen-sweep guard: box
geometry, opacity, text and generated content, form-control state, painted CSS
-counters, clip-path, variable-font axes, and the pixels of visible
-canvas/video/img elements all count as motion — so a playing same-origin (or
-CORS-readable) background video keeps a scope live on its own. Elements under
+counters, clip-path, variable-font axes, filters, colors, backgrounds, drawn
+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
+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
the browser does not paint (`display: none` subtrees, `visibility: hidden`
-boxes, skipped `content-visibility` contents) never counts as motion either.
+boxes, skipped `content-visibility` contents) never counts as motion either,
+except a video's playback time, since the runtime may draw that video's
+picture elsewhere.
`duration`, `keepsMoving.withinSelector`, and `keepsMoving.maxStaticSec` are
optional. Findings use the same shape and envelope as layout findings and are
diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts
index 00387030065..69d629639e1 100644
--- a/packages/cli/src/commands/check.test.ts
+++ b/packages/cli/src/commands/check.test.ts
@@ -1512,11 +1512,39 @@ describe("check pipeline", () => {
(finding) =>
finding.code === "sweep_static" &&
finding.severity === "error" &&
- finding.message.includes("did not advance"),
+ finding.message.includes("did not advance") &&
+ finding.fixHint?.includes("data-no-timeline"),
),
).toBe(true);
});
+ it("warns, without failing, when only the audio advanced and nothing on screen moved", async () => {
+ let call = 0;
+ const driver = fakeDriver({
+ getDuration: vi.fn(async () => 6),
+ collectLayoutGeometry: vi.fn(async () => `still\u001f${call++}`),
+ });
+ const { report } = await runScenario(driver);
+
+ const sweep = report.layout.findings.filter((finding) => finding.code === "sweep_static");
+ expect(sweep.map((finding) => [finding.severity, finding.message])).toEqual([
+ ["warning", "Only the audio advanced under seek; nothing on screen moved."],
+ ]);
+ expect(sweep[0]?.fixHint).toContain("data-no-timeline");
+ expect(report.ok).toBe(true);
+ });
+
+ it("does not flag a sweep where something on screen moved", async () => {
+ let call = 0;
+ const driver = fakeDriver({
+ getDuration: vi.fn(async () => 6),
+ collectLayoutGeometry: vi.fn(async () => `frame${call++}\u001f0`),
+ });
+ const { report } = await runScenario(driver);
+
+ expect(report.layout.findings.some((finding) => finding.code === "sweep_static")).toBe(false);
+ });
+
it("does not flag --at times the user picked on a still end card", async () => {
const driver = fakeDriver({
getDuration: vi.fn(async () => 53.7),
diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js
index 99519273b6b..eec189178be 100644
--- a/packages/cli/src/commands/layout-audit.browser.js
+++ b/packages/cli/src/commands/layout-audit.browser.js
@@ -471,6 +471,15 @@
};
}
+ function capitalizeWords(text) {
+ const words = new Intl.Segmenter(undefined, { granularity: "word" }).segment(text);
+ return Array.from(words, ({ segment, isWordLike }) => {
+ if (!isWordLike) return segment;
+ const [first, ...rest] = segment;
+ return first.toUpperCase() + rest.join("");
+ }).join("");
+ }
+
function horizontalTextMetrics(element, style) {
if (style.writingMode && style.writingMode !== "horizontal-tb") return null;
const context = document.createElement("canvas").getContext("2d");
@@ -479,6 +488,7 @@
let text = textContentFor(element, true);
if (style.textTransform === "uppercase") text = text.toUpperCase();
if (style.textTransform === "lowercase") text = text.toLowerCase();
+ if (style.textTransform === "capitalize") text = capitalizeWords(text);
const metrics = context.measureText(text);
return metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent > 0 ? metrics : null;
}
@@ -490,6 +500,16 @@
);
}
+ // The glyphs' own extent inside a Range rect (the font's content area); font metrics scale with the rect.
+ function inkRect(rect, metrics) {
+ const scale = rect.height / (metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent);
+ const top =
+ rect.top + (metrics.fontBoundingBoxAscent - metrics.actualBoundingBoxAscent) * scale;
+ const bottom =
+ rect.bottom - (metrics.fontBoundingBoxDescent - metrics.actualBoundingBoxDescent) * scale;
+ return { ...rect, top, bottom, height: bottom - top };
+ }
+
function visibleTextLineRects(element, rects, style, clip, tolerance) {
const metrics = horizontalTextMetrics(element, style);
const fontHeight = metrics ? metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent : 0;
@@ -497,12 +517,7 @@
return rects.flatMap((rect) => {
if (!metrics) return intersectsTextWindow(rect, clip, tolerance) ? [rect] : [];
const scale = rect.height / fontHeight;
- const inkTop =
- rect.top + (metrics.fontBoundingBoxAscent - metrics.actualBoundingBoxAscent) * scale;
- const inkBottom =
- rect.bottom - (metrics.fontBoundingBoxDescent - metrics.actualBoundingBoxDescent) * scale;
- const ink = { ...rect, top: inkTop, bottom: inkBottom };
- if (!intersectsTextWindow(ink, clip, tolerance)) return [];
+ if (!intersectsTextWindow(inkRect(rect, metrics), clip, tolerance)) return [];
// Negative leading belongs outside the used line box. Font metrics scale
// with the Range rect, so zoomed cards retain the same clipping decision.
const inset = Math.max(0, (rect.height - lineHeight * scale) / 2);
@@ -718,6 +733,20 @@
return total;
}
+ function overlapsByAFifth(a, b) {
+ return fragmentIntersectionArea(a, b) > Math.min(rectsArea(a), rectsArea(b)) * 0.2;
+ }
+
+ // Collision is judged on the glyphs: at line-height < 1 the content areas of stacked lines overlap
+ // while the words do not touch. Measured only for pairs whose content areas already overlap.
+ function glyphRects(block) {
+ if (!block.glyphRects) {
+ const metrics = horizontalTextMetrics(block.element, getComputedStyle(block.element));
+ block.glyphRects = metrics ? block.rects.map((rect) => inkRect(rect, metrics)) : block.rects;
+ }
+ return block.glyphRects;
+ }
+
function isNested(a, b) {
return a.contains(b) || b.contains(a);
}
@@ -753,8 +782,8 @@
function overlapIssue(a, b, time) {
if (isNested(a.element, b.element)) return null;
if (isManagedFlowOverlap(a.element, b.element)) return null;
- const area = fragmentIntersectionArea(a.rects, b.rects);
- if (area <= Math.min(rectsArea(a.rects), rectsArea(b.rects)) * 0.2) return null;
+ if (!overlapsByAFifth(a.rects, b.rects)) return null;
+ if (!overlapsByAFifth(glyphRects(a), glyphRects(b))) return null;
return {
// Warning at the per-sample level: a single-sample overlap is usually an
// entrance/exit transient (two blocks crossing mid-animation), not a real
diff --git a/packages/cli/src/commands/layout-audit.chromium.test.ts b/packages/cli/src/commands/layout-audit.chromium.test.ts
index 4162912ba93..caa04affb91 100644
--- a/packages/cli/src/commands/layout-audit.chromium.test.ts
+++ b/packages/cli/src/commands/layout-audit.chromium.test.ts
@@ -11,6 +11,13 @@ declare global {
const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
const script = readFileSync(new URL("./layout-audit.browser.js", import.meta.url), "utf8");
+const heading = (top: number, text: string, style = "") =>
+ `
',
+ ),
+ );
+ const before = await sample();
+ await mutate('document.getElementById("media").currentTime = 4');
+ const after = await sample();
+
+ expect(after.sweep).toBe(before.sweep);
+ });
+
it("sees a clip-path wipe over a box that never moves", async () => {
await load(
composition(
diff --git a/packages/cli/src/commands/motion-signature.browser.js b/packages/cli/src/commands/motion-signature.browser.js
index 9b11c084b08..97414f639a0 100644
--- a/packages/cli/src/commands/motion-signature.browser.js
+++ b/packages/cli/src/commands/motion-signature.browser.js
@@ -9,6 +9,7 @@
const FNV_PRIME = 16777619;
const LIVENESS_POSITION_BUCKET_PX = 2;
const LIVENESS_OPACITY_BUCKET = 0.08;
+ const LIVENESS_MEDIA_TIME_BUCKET_SEC = 0.1;
const IGNORE_SELECTOR = "[data-layout-ignore], [data-layout-check='ignore']";
// counter(name) / counters(name, sep) in generated content. A list-item box
// whose ::marker content is `normal` paints counter(list-item) implicitly.
@@ -139,6 +140,34 @@
return clip ? hashFields([clip]) : "";
}
+ // Paint that moves no geometry: a filter, color, background, shadow or SVG paint tween restyles a box in place.
+ function paintChannel(properties) {
+ return (element, ctx) => {
+ const values = properties.map((property) => cssValue(ctx.style[property]));
+ return values.some(Boolean) ? hashFields(values) : "";
+ };
+ }
+ const backgroundPaintChannel = paintChannel([
+ "filter",
+ "backdropFilter",
+ "backgroundColor",
+ "backgroundImage",
+ "backgroundPosition",
+ "boxShadow",
+ ]);
+
+ const STROKE_EDGES = ["borderTop", "borderRight", "borderBottom", "borderLeft", "outline"];
+ // Blink computes border and outline colors as `currentColor` even when none is drawn, so they follow `color`.
+ function drawnColor(style, edge) {
+ const drawn = style[`${edge}Style`] !== "none" && Number.parseFloat(style[`${edge}Width`]) > 0;
+ return drawn ? style[`${edge}Color`] : "";
+ }
+ function strokePaintChannel(element, ctx) {
+ const values = STROKE_EDGES.map((edge) => drawnColor(ctx.style, edge));
+ return values.some(Boolean) ? hashFields(values) : "";
+ }
+ const contentPaintChannel = paintChannel(["color", "textShadow", "fill", "stroke"]);
+
// Direct text nodes only: descendants are signed separately, and a hidden
// descendant's text mutation must not masquerade as visible motion.
function textChannel(element) {
@@ -223,12 +252,15 @@
fontAxesChannel,
clipPathChannel,
controlWidgetChannel,
+ backgroundPaintChannel,
+ strokePaintChannel,
];
const CONTENT_CHANNELS = [
textChannel,
controlValueChannel,
generatedContentChannel,
mediaPixelChannel,
+ contentPaintChannel,
];
const ELEMENT_CHANNELS = [...BOX_CHANNELS, ...CONTENT_CHANNELS];
@@ -302,6 +334,20 @@
return parts;
}
+ // Media time moves under seek where no pixel can be read: a graded video is drawn into a WebGL canvas that
+ // reads back blank, and audio has no box. Audio shows the timeline ran but is no picture, so it is kept apart.
+ function mediaTimeParts(root, quantize, selector) {
+ const parts = [];
+ for (const media of root.querySelectorAll(selector)) {
+ if (isOptedOut(media, root)) continue;
+ const time = media.currentTime;
+ parts.push(
+ String(quantize ? Math.round(time / LIVENESS_MEDIA_TIME_BUCKET_SEC) : round(time)),
+ );
+ }
+ return parts;
+ }
+
// One signature of everything under `root`, root included, that a viewer could see change between seeks.
// Opted-out elements inside the root may animate off the timeline, so they are neither signed nor counter
// consumers, but stay counter owners; the root itself is always measured.
@@ -353,7 +399,7 @@
const channels = skipped ? BOX_CHANNELS : ELEMENT_CHANNELS;
parts.push(channels.map((channel) => channel(element, ctx)).join(","));
}
- parts.push(...counterParts(root, boxOwners));
+ parts.push(...counterParts(root, boxOwners), ...mediaTimeParts(root, quantize, "video"));
return parts.join("|");
}
@@ -369,6 +415,9 @@
// The name predates the textual/media channels and is kept for driver
// compatibility.
window.__hyperframesLayoutGeometry = function collectLayoutGeometry() {
- return compositionSignature(compositionRoot(), { quantize: false });
+ const root = compositionRoot();
+ // AUDIO_TIME_SEPARATOR in utils/checkPipeline.ts: what follows it is audio time, not something seen.
+ const audio = root ? mediaTimeParts(root, false, "audio").join("|") : "";
+ return `${compositionSignature(root, { quantize: false })}\u001f${audio}`;
};
})();
diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts
index 8e6d8668690..d1b148dfdd6 100644
--- a/packages/cli/src/utils/checkPipeline.ts
+++ b/packages/cli/src/utils/checkPipeline.ts
@@ -524,24 +524,39 @@ function detectSweepStatic(
if (duration < SWEEP_STATIC_MIN_DURATION_SEC) return [];
if (layoutStateSignatures.length < 2) return [];
if (motionIssues.some((issue) => issue.code === "motion_frozen")) return [];
- const [first, ...rest] = layoutStateSignatures;
- if (!first || rest.some((signature) => signature !== first)) return [];
- return [
- {
- code: "sweep_static",
- severity: "error",
- time: 0,
- selector: "[data-composition-id]",
- dataAttributes: {},
- sourceFile: "index.html",
- bbox: ZERO_BBOX,
- rect: ZERO_LAYOUT_RECT,
- message:
- "Timeline did not advance under seek; every green verdict on this run is unreliable.",
- fixHint:
- "Confirm the composition seeks a paused GSAP/CSS timeline under `data-*` timing attributes rather than only autoplaying.",
- },
- ];
+ if (allSame(layoutStateSignatures)) return [sweepStaticIssue("error")];
+ if (allSame(layoutStateSignatures.map(seenPart))) return [sweepStaticIssue("warning")];
+ return [];
+}
+
+// motion-signature.browser.js appends audio time after this; a signature without it is all "seen".
+const AUDIO_TIME_SEPARATOR = "\u001f";
+
+function seenPart(signature: string): string {
+ return signature.split(AUDIO_TIME_SEPARATOR)[0] ?? signature;
+}
+
+function allSame(values: string[]): boolean {
+ return values.every((value) => value === values[0]);
+}
+
+function sweepStaticIssue(severity: "error" | "warning"): AnchoredLayoutIssue {
+ return {
+ code: "sweep_static",
+ severity,
+ 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.",
+ 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.",
+ };
}
// rotation_pivot_drift: bbox center should stay fixed while the element spins.
diff --git a/skills-manifest.json b/skills-manifest.json
index ccf17352843..5f40e23b966 100644
--- a/skills-manifest.json
+++ b/skills-manifest.json
@@ -30,7 +30,7 @@
"files": 7
},
"hyperframes-cli": {
- "hash": "14c7aa640f679d2a",
+ "hash": "cf031cc9764e451f",
"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 f866ae5c30f..07d36146444 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 `