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 = "") => + `

${text}

`; +const WORDS_08 = + '

Launchfastershipsooner

'; +const WORDS_04 = + '

Launchfastershipsooner

'; + describe.runIf(executablePath)("layout audit in Chromium", () => { let browser: Browser; beforeAll(async () => { @@ -20,6 +27,20 @@ describe.runIf(executablePath)("layout audit in Chromium", () => { await browser?.close(); }); + async function auditCodes(body: string): Promise { + const page = await browser.newPage(); + try { + await page.setContent(`${body}`); + await page.addScriptTag({ content: script }); + const issues = await page.evaluate(() => + window.__hyperframesLayoutAudit({ time: 1, tolerance: 2 }), + ); + return issues.map((issue) => issue.code); + } finally { + await page.close(); + } + } + it.each([ { name: "line-height 1", css: "height:120px", textStyle: "", error: false }, { name: "line-height .9", css: "height:108px;line-height:.9", textStyle: "", error: false }, @@ -62,21 +83,55 @@ describe.runIf(executablePath)("layout audit in Chromium", () => { error: true, }, ])("handles $name", async ({ css, textStyle, error }) => { - const page = await browser.newPage(); - try { - await page.setContent(` + const codes = await auditCodes(`
HELLO
-
`); - await page.addScriptTag({ content: script }); - const issues = await page.evaluate(() => - window.__hyperframesLayoutAudit({ time: 1, tolerance: 2 }), - ); - expect(issues.some((issue) => issue.code === "text_box_overflow")).toBe(error); - } finally { - await page.close(); - } + `); + expect(codes.includes("text_box_overflow")).toBe(error); + }); + it.each([ + { name: "inline-block words wrapping at line-height .8", html: WORDS_08, overlap: false }, + { + name: "the same words at line-height .4, where the glyphs collide", + html: WORDS_04, + overlap: true, + }, + { + name: "two headings 60px apart whose letters collide", + html: heading(100, "HELLO") + heading(160, "WORLD"), + overlap: true, + }, + { + name: "capitalized words 60px apart whose capitals collide", + html: + heading(100, "ace", "text-transform:capitalize") + + heading(160, "ace", "text-transform:capitalize"), + overlap: true, + }, + { + name: "capitalized words led by punctuation, whose capitals collide", + html: + heading(100, "\u2026ace", "text-transform:capitalize") + + heading(160, "-ace", "text-transform:capitalize"), + overlap: true, + }, + { + name: "capitalized words led by a digit, which stay lowercase, whose descenders collide", + html: + heading(100, "4you", "text-transform:capitalize") + + heading(175, "4you", "text-transform:capitalize"), + overlap: true, + }, + { + name: "two headings placed on the same spot", + html: '

HELLO

WORLD

', + overlap: true, + }, + ])("judges text overlap on the glyphs: $name", async ({ html, overlap }) => { + const codes = await auditCodes(` +
${html}
`); + expect(codes.includes("content_overlap")).toBe(overlap); }); }); diff --git a/packages/cli/src/commands/motion-signature.browser.chromium.test.ts b/packages/cli/src/commands/motion-signature.browser.chromium.test.ts index c337d001820..155f646c7ef 100644 --- a/packages/cli/src/commands/motion-signature.browser.chromium.test.ts +++ b/packages/cli/src/commands/motion-signature.browser.chromium.test.ts @@ -330,6 +330,109 @@ describe.skipIf(!RUNS_CHROMIUM)("motion-signature.browser in Chromium", () => { expect(after.sweep).not.toBe(before.sweep); }); + it.each([ + ["a blur", "#title { color: #fff; }", "filter = 'blur(8px)'"], + ["a text color", "#title { color: #fff; }", "color = 'rgb(255, 51, 0)'"], + ["a background color", "#title { background: #000; }", "backgroundColor = 'rgb(40, 0, 0)'"], + ["a box shadow", "#title { box-shadow: none; }", "boxShadow = '0 0 12px red'"], + ])("sees %s tween on a title that never moves", async (_, css, change) => { + await load(composition(css, '

Title

')); + const before = await sample(); + await mutate(`document.getElementById("title").style.${change}`); + const after = await sample(); + + expect(after.sweep).not.toBe(before.sweep); + expect(after.liveness).not.toBe(before.liveness); + }); + + it("sees an SVG shape's fill color change", async () => { + await load( + composition( + "", + '', + ), + ); + const before = await sample(); + await mutate('document.getElementById("shape").style.fill = "rgb(255, 51, 0)"'); + const after = await sample(); + + expect(after.sweep).not.toBe(before.sweep); + expect(after.liveness).not.toBe(before.liveness); + }); + + it("sees the time of a video whose picture is drawn elsewhere advance, in both samplers", async () => { + await load( + composition( + "", + '

Title

', + ), + ); + const before = await sample(); + await mutate('document.getElementById("media").currentTime = 4'); + const after = await sample(); + + expect(after.sweep).not.toBe(before.sweep); + expect(after.liveness).not.toBe(before.liveness); + }); + + it("counts audio time as the timeline running, but never as a moving picture", async () => { + await load(composition("", '

Title

')); + const before = await sample(); + await mutate('document.getElementById("media").currentTime = 4'); + const after = await sample(); + + expect(after.sweep).not.toBe(before.sweep); + expect(after.liveness).toBe(before.liveness); + }); + + it.each([ + ["no border", ""], + ["a border drawn on one side in its own color", "border-bottom: 2px solid #fff;"], + ])( + "ignores a color change on a skipped host with %s, though Blink resolves undrawn border colors from it", + async (_, border) => { + await load( + composition( + `#host { content-visibility: hidden; width: 200px; height: 80px; color: #fff; ${border} }`, + '
Title
', + ), + ); + const before = await sample(); + await mutate('document.getElementById("host").style.color = "rgb(255, 51, 0)"'); + const after = await sample(); + + expect(after.sweep).toBe(before.sweep); + }, + ); + + it.each([ + ["border", "border: 4px solid #fff;", "borderColor"], + ["outline", "outline: 4px solid #fff;", "outlineColor"], + ])("sees a drawn %s change color", async (_, stroke, property) => { + await load( + composition(`#box { width: 120px; height: 60px; ${stroke} }`, '
'), + ); + const before = await sample(); + await mutate(`document.getElementById("box").style.${property} = "rgb(255, 51, 0)"`); + const after = await sample(); + + expect(after.sweep).not.toBe(before.sweep); + }); + + it("ignores media time on a data-layout-ignore layer", async () => { + await load( + composition( + "", + '

Title

', + ), + ); + 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 `` 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 zero geometry change across every sample, `check` fails with `sweep_static`: a frozen timeline makes every green verdict unreliable, so it refuses to pass. 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 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. **Escape hatches** (mark intent in the HTML, then re-run):