diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 617de057dd..4621f3d27e 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -547,7 +547,7 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o | `--caption-zone ""` | Opt-in band gate: flags content whose center sits inside the fractional band (optional `severity`, `seek`) | | `--frame-check` | Opt-in media out-of-frame detection (img/svg/video/canvas) | - Contrast failures are **errors** and include the sampled fg/bg colors, measured vs required ratio, and a suggested compliant color. Severity is persistence-aware: single-sample transients demote to info, held findings gate the exit code, and a frozen timeline on a 3s+ composition fails with `sweep_static`. + Contrast failures are **errors** and include the sampled fg/bg colors, measured vs required ratio, and a suggested compliant color. Severity is persistence-aware: single-sample transients demote to info, held findings gate the exit code, and a frozen timeline on a 3s+ composition fails with `sweep_static` — unless the root declares `data-layout-allow-static`, which reports it at info instead. ### `beats` diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index 8d9a07f97e..b4607d9742 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -145,6 +145,7 @@ function fakeDriver(overrides: Partial = {}): CheckAuditDriver getDuration: vi.fn(async () => 9), getTransitionBoundaries: vi.fn(async () => []), getCanvas: vi.fn(async () => ({ width: 1920, height: 1080 })), + hasStaticOptOut: vi.fn(async () => false), findAmbiguousSelectors: vi.fn(async (_selectors: string[]) => []), seek: vi.fn(async (_time: number) => undefined), seekGeometry: vi.fn(async (_time: number) => undefined), @@ -1095,6 +1096,36 @@ describe("check pipeline", () => { ).toBe(true); }); + it("reports at info, without gating, when the root declares data-layout-allow-static", async () => { + // A held title card had no passing spelling before. The claim is the author's and can be + // wrong, so the record survives at info rather than vanishing. + const driver = fakeDriver({ + getDuration: vi.fn(async () => 6), + collectLayoutGeometry: vi.fn(async () => "frozen"), + hasStaticOptOut: vi.fn(async () => true), + }); + const { report } = await runScenario(driver); + + const sweep = report.layout.findings.find((finding) => finding.code === "sweep_static"); + expect(sweep?.severity).toBe("info"); + expect(sweep?.message).toContain("data-layout-allow-static"); + expect(report.ok).toBe(true); + }); + + it("keeps the unqualified wording when the root does not declare the opt-out", async () => { + const driver = fakeDriver({ + getDuration: vi.fn(async () => 6), + collectLayoutGeometry: vi.fn(async () => "frozen"), + hasStaticOptOut: vi.fn(async () => false), + }); + const { report } = await runScenario(driver); + + const sweep = report.layout.findings.find((finding) => finding.code === "sweep_static"); + expect(sweep?.severity).toBe("error"); + expect(sweep?.message).toContain("did not advance"); + expect(sweep?.message).not.toContain("data-layout-allow-static"); + }); + it("does not flag a 1.5s static title card — too short for the guard to apply", async () => { const driver = fakeDriver({ getDuration: vi.fn(async () => 1.5), diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 382184309a..7914e5332f 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1471,11 +1471,22 @@ } } - window.__hyperframesLayoutGeometry = function collectLayoutGeometry() { - const root = + /** The composition root, resolved identically for the fingerprint and the opt-out that excuses it. */ + function compositionRoot() { + return ( document.querySelector("[data-composition-id][data-width][data-height]") || document.querySelector("[data-composition-id]") || - document.body; + document.body + ); + } + + /** Root-only: a held title card declares itself motionless, so the frozen-sweep guard reports without gating. */ + window.__hyperframesStaticOptOut = function collectStaticOptOut() { + return compositionRoot().hasAttribute("data-layout-allow-static"); + }; + + window.__hyperframesLayoutGeometry = function collectLayoutGeometry() { + const root = compositionRoot(); const elements = Array.from(root.querySelectorAll("*")).filter((element) => isVisibleElement(element), ); diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index eb551164ba..e8081bc7ac 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -227,6 +227,50 @@ describe("layout-audit.browser", () => { expect(textOverflowCodes()).toEqual([]); }); + // Root-only by design: a stray attribute on a child must not excuse a frozen sweep for the + // whole run, and the root is resolved exactly as the fingerprint resolves it. + describe("static opt-out", () => { + function optOutFor(html: string): boolean { + document.body.innerHTML = html; + installAuditScript(); + const probe = Reflect.get(window, "__hyperframesStaticOptOut"); + if (typeof probe !== "function") throw new Error("probe missing"); + return Reflect.apply(probe, window, []) === true; + } + + it("is satisfied by the attribute on the composition root", () => { + expect( + optOutFor( + `
`, + ), + ).toBe(true); + }); + + it("is NOT satisfied by the attribute on a child", () => { + expect( + optOutFor( + `
`, + ), + ).toBe(false); + }); + + it("is NOT satisfied by the attribute on a nested composition host", () => { + expect( + optOutFor( + `
`, + ), + ).toBe(false); + }); + + it("is absent when nothing declares it", () => { + expect( + optOutFor( + `
`, + ), + ).toBe(false); + }); + }); + it("does not flag glyph-ink vertical spill within the font-metric band on a non-clipping box", () => { // A painted, non-clipping caption-word-like box whose glyph ink (text rect) exceeds its snug // line-height box by a few px vertically — normal typography, nothing is clipped. (fontSize diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index c47cced9ca..4959d9e029 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -344,6 +344,7 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud getTransitionBoundaries: () => collectTweenBoundaries(page), getCanvas: () => page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })), + hasStaticOptOut: () => evaluateStaticOptOut(page), findAmbiguousSelectors: (selectors) => findAmbiguousSelectors(page, selectors), seek: async (time) => { setTime(time); @@ -483,6 +484,14 @@ async function collectOverlap(page: Page, time: number): Promise { + return page.evaluate(() => { + const probe = Reflect.get(window, "__hyperframesStaticOptOut"); + if (typeof probe !== "function") return false; + return Reflect.apply(probe, window, []) === true; + }); +} + async function collectLayoutGeometry(page: Page): Promise { return page.evaluate(() => { const geometry = Reflect.get(window, "__hyperframesLayoutGeometry"); diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index a02fb2fa5b..38ae22a5f9 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -473,11 +473,17 @@ const ZERO_LAYOUT_RECT: LayoutRect = { * 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). + * + * A root marked `data-layout-allow-static` is a held title card or logo lockup + * — motionless on purpose, and previously unpassable. That claim is the + * author's and can be wrong, so the finding is still emitted, at `info`: it + * keeps the record that every other verdict came from one repeated frame. */ function detectSweepStatic( duration: number, geometrySignatures: string[], motionIssues: AnchoredLayoutIssue[], + staticOptOut: boolean, ): AnchoredLayoutIssue[] { if (duration < SWEEP_STATIC_MIN_DURATION_SEC) return []; if (geometrySignatures.length < 2) return []; @@ -487,17 +493,19 @@ function detectSweepStatic( return [ { code: "sweep_static", - severity: "error", + severity: staticOptOut ? "info" : "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.", + message: staticOptOut + ? "No geometry change across the sampled grid; this composition declares data-layout-allow-static, so every other verdict reflects a single frame." + : "Timeline did not advance under seek; every green verdict on this run is unreliable.", + fixHint: staticOptOut + ? "Remove data-layout-allow-static if this composition is meant to animate." + : "Confirm the composition seeks a paused GSAP/CSS timeline under `data-*` timing attributes rather than only autoplaying.", }, ]; } @@ -1012,6 +1020,7 @@ export async function runAuditGrid( grid.duration, collected.geometrySignatures, motionIssues, + await driver.hasStaticOptOut(), ); const rotationFindings = detectRotationPivotDrift( collected.rotationSamples, diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts index c334787e37..47727a1233 100644 --- a/packages/cli/src/utils/checkTypes.ts +++ b/packages/cli/src/utils/checkTypes.ts @@ -171,6 +171,8 @@ export interface CheckAuditDriver { getDuration(): Promise; getTransitionBoundaries(): Promise; getCanvas(): Promise; + /** Whether the composition root declares it is deliberately motionless, so the frozen-sweep guard reports without gating. */ + hasStaticOptOut(): Promise; findAmbiguousSelectors(selectors: string[]): Promise; seek(time: number): Promise; /** Settle-free seek for the geometry-only dense content_overlap pass; only collectOverlap consumes it, and getBoundingClientRect is valid synchronously after setTime. */ diff --git a/skills-manifest.json b/skills-manifest.json index 4cb98f52e0..5170c4cd53 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -26,11 +26,11 @@ "files": 121 }, "hyperframes-cli": { - "hash": "a4db0693ff88e760", + "hash": "cba64cc502702676", "files": 11 }, "hyperframes-core": { - "hash": "773fc6d15d9e87b3", + "hash": "6c4061695aa3f09d", "files": 19 }, "hyperframes-creative": { diff --git a/skills/hyperframes-cli/references/lint-validate-inspect.md b/skills/hyperframes-cli/references/lint-validate-inspect.md index 3d6b13f1e5..1ca452ce41 100644 --- a/skills/hyperframes-cli/references/lint-validate-inspect.md +++ b/skills/hyperframes-cli/references/lint-validate-inspect.md @@ -50,11 +50,12 @@ 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. 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. 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. A piece that is motionless _by design_ is the exception: mark the root `data-layout-allow-static` and `sweep_static` drops to info, keeping the record that every other verdict came from one repeated frame. **Escape hatches** (mark intent in the HTML, then re-run): - `data-layout-allow-overflow` — overflow is intentional (entrance/exit travel). +- `data-layout-allow-static` — on the composition root: this piece is deliberately motionless (held title card, logo lockup), so `sweep_static` reports at info instead of failing. - `data-layout-allow-overlap` — deliberate text layering (e.g. a demo cursor label over a heading). - `data-layout-allow-occlusion` — an element is meant to cover text. - `data-layout-ignore` — decorative element that should never be audited. diff --git a/skills/hyperframes-core/references/data-attributes.md b/skills/hyperframes-core/references/data-attributes.md index fcaae3362d..b1a991d478 100644 --- a/skills/hyperframes-core/references/data-attributes.md +++ b/skills/hyperframes-core/references/data-attributes.md @@ -55,6 +55,7 @@ See `sub-compositions.md` for the full wiring pattern. - `id="root"` — template convention used by scaffolds and the transition catalog so CSS can target the composition root with `#root` instead of `[data-composition-id="main"]`. Not required by the runtime, but consistent with the rest of the ecosystem. - `class="clip"` — required runtime visibility marker on visible timed elements (`
`, ``, …). See Clip Attributes above. +- `data-layout-allow-static` — on the composition root only: declares the piece deliberately motionless (held title card, logo lockup). `sweep_static` then reports at info instead of failing the run. Layout-audit only; no render-path effect. Remove it if the composition is meant to animate. - `data-layout-allow-overflow` — tells `hyperframes check` that overflow on this element (or its descendants) is intentional. Notes: - The `check` layout audit measures `getBoundingClientRect` at sampled timestamps, not rendered pixels. `overflow: hidden` clips the visual but does **not** suppress a layout finding. This attribute is the escape hatch; CSS overflow is not. - Can be set on the composition **root** as well as on any child. When the cited offender is `div.-root inside div.-root` (the root reports its own children's union as overflowing), the fix goes on the root, not on individual text descendants — shrinking font sizes will not converge.