From 3a7950fd63b501788f39f1fd44c12fc317139d79 Mon Sep 17 00:00:00 2001 From: Xuanru Li <157947275+xuanruli@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:56:43 -0700 Subject: [PATCH] feat(check): add data-layout-allow-caption-zone waiver (#2853) * feat(check): add data-layout-allow-caption-zone waiver Opt intentional lower-third copy out of caption_zone_collision. Co-authored-by: Cursor * fix(check): address caption-zone waiver review nits Co-authored-by: Cursor * docs(skills): document caption-zone waiver on CLI agent path Co-authored-by: Cursor * docs(cli): document caption-zone waiver under check, not inspect Co-authored-by: Cursor --------- Co-authored-by: Cursor --- docs/packages/cli.mdx | 4 ++- packages/cli/src/commands/check.test.ts | 22 +++++++++++++++ .../cli/src/commands/layout-audit.browser.js | 6 +++- .../src/commands/layout-audit.browser.test.ts | 21 ++++++++++++++ packages/cli/src/utils/checkPipeline.ts | 28 +++++++++++++------ skills-manifest.json | 4 +-- .../references/lint-validate-inspect.md | 5 ++-- .../references/data-attributes.md | 1 + 8 files changed, 77 insertions(+), 14 deletions(-) diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 989d9b7be..ced38a57f 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -550,6 +550,8 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o 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`. + Escape hatches (mark intent in HTML, then re-run): `data-layout-allow-overflow` / `data-layout-allow-overlap` / `data-layout-allow-occlusion` / `data-layout-ignore` for the usual layout audits. For intentional lower-third copy under `--caption-zone`, mark `data-layout-allow-caption-zone` on the element or an ancestor (`closest`); it silences only `caption_zone_collision` (not overflow, overlap, occlusion, or contrast) — prefer the narrowest wrapper that owns the band copy. + ### `beats` Detect the beats in a composition's music track and write them to a beat file the Studio uses to draw beat guides on the timeline: @@ -612,7 +614,7 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o | `--max-issues` | Maximum findings to print or return after static collapse (default: 80) | | `--strict` | Exit non-zero on warnings as well as errors | - Use `data-layout-allow-overflow` on an element or ancestor when overflow is intentional, such as a planned off-canvas entrance. Use `data-layout-ignore` for decorative elements that should not be audited. Use `data-layout-allow-overlap` on a text element that is intentionally stacked over another (for example a lower-third caption above a heading). Use `data-layout-allow-occlusion` when text is intentionally layered beneath another element (for example a caption behind a foreground prop). + Use `data-layout-allow-overflow` on an element or ancestor when overflow is intentional, such as a planned off-canvas entrance. Use `data-layout-ignore` for decorative elements that should not be audited. Use `data-layout-allow-overlap` on a text element that is intentionally stacked over another (for example a lower-third caption above a heading). Use `data-layout-allow-occlusion` when text is intentionally layered beneath another element (for example a caption behind a foreground prop). For `--caption-zone` / `data-layout-allow-caption-zone`, see [`check`](#check). `layout` remains available as a compatibility alias for the same visual inspection pass: diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index 050722eb5..e4217f4f1 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -175,11 +175,13 @@ interface GeometryFixture { elementRect?: LayoutRect; time: number; overflow?: LayoutOverflow; + dataAttributes?: Record; } function geometryCandidate(fixture: GeometryFixture) { return { ...anchor(fixture.selector, fixture.time), + ...(fixture.dataAttributes ? { dataAttributes: fixture.dataAttributes } : {}), kind: fixture.kind, tag: fixture.tag, text: fixture.text, @@ -446,6 +448,26 @@ it("flags only text whose center is inside the caption band at the default end s expect(report.ok).toBe(true); }); +it("skips caption_zone_collision when data-layout-allow-caption-zone is set", async () => { + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "text", + tag: "div", + text: "Intentional lower third", + selector: "#lower-third", + rect: fixtureRect(860, 870, 200, 60), + time, + dataAttributes: { "data-layout-allow-caption-zone": "" }, + }), + ]); + const { report } = await runScenario( + fakeDriver({ getDuration: vi.fn(async () => 10), collectGeometryCandidates }), + { samples: 1, contrast: false, captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 0.9 } }, + ); + + expect(report.layout.findings).toEqual([]); +}); + it("filters caption candidates by the element box while centering the text rect", async () => { const collectGeometryCandidates = vi.fn(async (time: number) => [ geometryCandidate({ diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index e37e7853d..8c86391cc 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -105,6 +105,10 @@ return !!element.closest("[data-layout-allow-overflow]"); } + function hasAllowCaptionZoneFlag(element) { + return !!element.closest("[data-layout-allow-caption-zone]"); + } + function hasTextClipOptOut(element) { return hasAllowOverflowFlag(element) || element.hasAttribute("data-layout-bleed"); } @@ -1396,7 +1400,7 @@ } if (!isVisibleElement(element, 0.05, false)) continue; const elementRect = toRect(element.getBoundingClientRect()); - if (includeText && hasOwnTextCandidate(element, true)) { + if (includeText && hasOwnTextCandidate(element, true) && !hasAllowCaptionZoneFlag(element)) { const rect = textRectFor(element, true); if (rect) { candidates.push( diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 849db6424..fbc527f87 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -381,6 +381,27 @@ it("returns own-text rects and media overflow while excluding caption layers", ( expect(candidates.some((candidate) => candidate.selector === "#caption")).toBe(false); }); +it("excludes text marked data-layout-allow-caption-zone from geometry candidates", () => { + document.body.innerHTML = ` +
+

Main copy

+

Lower third

+
Nested lower
+
+ `; + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + copy: rect({ left: 100, top: 100, width: 200, height: 40 }), + lower: rect({ left: 100, top: 280, width: 200, height: 40 }), + nested: rect({ left: 100, top: 300, width: 200, height: 40 }), + text: rect({ left: 100, top: 100, width: 200, height: 40 }), + }); + installAuditScript(); + + const candidates = runGeometryCandidates({ text: true, media: false, tolerance: 2 }); + expect(candidates.map((candidate) => candidate.selector)).toEqual(["#copy"]); +}); + it("scans body-level composition siblings and includes a media boundary root", () => { document.body.innerHTML = ` diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index 3f8fb0795..cf98188fe 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -238,6 +238,21 @@ function geometryIssueAnchor(candidate: CheckGeometryCandidate, time: number) { }; } +function captionCenterInZone( + rect: CheckGeometryCandidate["rect"], + zone: NonNullable, + canvas: Canvas, +): { inside: boolean; cy: number } { + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + const inside = + cx >= zone.x0 * canvas.width && + cx <= zone.x1 * canvas.width && + cy >= zone.y0 * canvas.height && + cy <= zone.y1 * canvas.height; + return { inside, cy }; +} + function captionFinding( candidate: CheckGeometryCandidate, options: CheckOptions, @@ -246,13 +261,9 @@ function captionFinding( ): { key: string; issue: AnchoredLayoutIssue } | null { const zone = options.captionZone; if (!zone || candidate.kind !== "text" || !candidateIsSized(candidate, canvas)) return null; - const cx = candidate.rect.left + candidate.rect.width / 2; - const cy = candidate.rect.top + candidate.rect.height / 2; - const inside = - cx >= zone.x0 * canvas.width && - cx <= zone.x1 * canvas.width && - cy >= zone.y0 * canvas.height && - cy <= zone.y1 * canvas.height; + // Backstop for mocks/non-browser sources; browser already strips via closest() (own attrs only here). + if ("data-layout-allow-caption-zone" in candidate.dataAttributes) return null; + const { inside, cy } = captionCenterInZone(candidate.rect, zone, canvas); if (!inside) return null; const text = candidate.text.slice(0, 48); const pctFromBottom = Math.round(((canvas.height - cy) / canvas.height) * 100); @@ -264,7 +275,8 @@ function captionFinding( severity: zone.severity === "error" ? "error" : "warning", text, message: `<${candidate.tag}> "${text}" is centred in the reserved caption band (~${pctFromBottom}% up from the bottom).`, - fixHint: "Keep main content outside the configured caption band.", + fixHint: + "Keep main content outside the configured caption band, or mark intentional lower-third copy with data-layout-allow-caption-zone.", }, }; } diff --git a/skills-manifest.json b/skills-manifest.json index 6634da265..a7b36708e 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -26,11 +26,11 @@ "files": 121 }, "hyperframes-cli": { - "hash": "8c791e330873b1bd", + "hash": "966972db5ab8f932", "files": 11 }, "hyperframes-core": { - "hash": "773fc6d15d9e87b3", + "hash": "a270c70ced8cf952", "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 3d6b13f1e..5d3feecc1 100644 --- a/skills/hyperframes-cli/references/lint-validate-inspect.md +++ b/skills/hyperframes-cli/references/lint-validate-inspect.md @@ -9,7 +9,7 @@ When the composition is animation-driven, run the checks before you reach for `p - Run `lint` after the first HTML pass for early feedback. It is an iteration aid, not a separate final gate. - Run `check --snapshots` at the first full pass: the overview frames and per-finding crops show you what the auditor saw. - Look at the PNGs before tuning automated warnings: your eye catches what the auditor misses, and the auditor catches what your eye misses. -- Treat layout errors as defects unless a snapshot proves the layering is intentional, in which case mark it with `data-layout-allow-overflow` / `data-layout-allow-overlap` / `data-layout-allow-occlusion`. +- Treat layout errors as defects unless a snapshot proves the layering is intentional, in which case mark it with `data-layout-allow-overflow` / `data-layout-allow-overlap` / `data-layout-allow-occlusion` / `data-layout-allow-caption-zone` (caption band only). - State motion intent in a `*.motion.json` sidecar so `check` verifies it automatically (entrances firing under seek, stagger order, in-frame, liveness). This is the closest automated proxy for "watch the MP4" and catches render-vs-preview bugs the eye misses (see **Motion verification** below). ## lint @@ -57,6 +57,7 @@ Every finding carries a selector, the element's `data-*` identity, the compositi - `data-layout-allow-overflow` — overflow is intentional (entrance/exit travel). - `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-allow-caption-zone` — intentional lower-third / caption-band copy under `--caption-zone`. Applies to the marked element and every descendant (`closest`); silences only `caption_zone_collision` (not overflow/overlap/occlusion). Prefer the narrowest wrapper that owns the intentional band copy. - `data-layout-ignore` — decorative element that should never be audited. **Opt-in pipeline gates** (used by orchestrators; off by default): @@ -66,7 +67,7 @@ npx hyperframes check --caption-zone "x0=0;y0=.82;x1=1;y1=1;severity=error;seek= npx hyperframes check --frame-check # media (img/svg/video/canvas) out-of-frame detection ``` -`--caption-zone` takes fractional band geometry (`x0/y0/x1/y1` required, 0-1 fractions of the composition's own canvas, portrait included) with optional `severity` and comma-separated `seek` fractions; it flags content whose center sits inside the band. `--frame-check` reports media elements breaching the canvas beyond `max(120px, 6% of the min canvas dimension)`. +`--caption-zone` takes fractional band geometry (`x0/y0/x1/y1` required, 0-1 fractions of the composition's own canvas, portrait included) with optional `severity` and comma-separated `seek` fractions; it flags content whose center sits inside the band. Waive intentional lower-third copy with `data-layout-allow-caption-zone` on the element or its nearest wrapper (see Escape hatches). `--frame-check` reports media elements breaching the canvas beyond `max(120px, 6% of the min canvas dimension)`. **Fixing contrast errors** — thresholds are 4.5:1 for normal text, 3:1 for large text (24px+, or 19px+ bold). The finding's `suggestedColor` already picks the nearest compliant color in the right direction (brighten on dark backgrounds, darken on light); apply it or adjust within the palette family, then re-run `check`. diff --git a/skills/hyperframes-core/references/data-attributes.md b/skills/hyperframes-core/references/data-attributes.md index fcaae3362..ea8f0cdc1 100644 --- a/skills/hyperframes-core/references/data-attributes.md +++ b/skills/hyperframes-core/references/data-attributes.md @@ -61,6 +61,7 @@ See `sub-compositions.md` for the full wiring pattern. - In a multi-scene `group_wN.html` (continue runs), every scene-local element stays in the DOM during the other scenes' time windows; the layout-box union almost always overflows the canvas during morph seams. Mark the root and every scene-local primary/supporting element with this attribute **at construction**, not after `check` flags it. - **Blast radius — it silences more than the overflow audit.** The attribute is inherited down the subtree (the perception probe walks ancestors), so it also suppresses the rendered-perception checks `text-clipping`, `content-cramped-container`, and `foreground-over-panel` for every descendant. Putting it on a persistent panel that also hosts real foreground content disables collision checks on that content for the panel's whole lifetime. Prefer the narrowest opt-out: scope it to the smallest decorative wrapper, or use per-element `data-layout-bleed="true"` for one intentional primary-text crop. The two canvas/edge checks `primary-offscreen` and `foreground-over-panel` deliberately run **even under** allow-overflow, so it cannot hide a wordmark sliced by the frame or text bleeding onto a panel edge. - `data-layout-ignore` — exclude this element from layout audits entirely. +- `data-layout-allow-caption-zone` — opt out of `--caption-zone` / `caption_zone_collision` for intentional lower-third copy (applies to the element and every descendant via `closest`; does **not** suppress overflow, overlap, occlusion, or other layout audits — pair those attrs if needed). ## Legacy / Removed Attributes