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 <cursoragent@cursor.com>

* fix(check): address caption-zone waiver review nits

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(skills): document caption-zone waiver on CLI agent path

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(cli): document caption-zone waiver under check, not inspect

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xuanru Li
2026-07-28 15:56:43 -07:00
committed by GitHub
co-authored by Cursor
parent a996e91e5d
commit 3a7950fd63
8 changed files with 77 additions and 14 deletions
+3 -1
View File
@@ -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`. 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` ### `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: 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) | | `--max-issues` | Maximum findings to print or return after static collapse (default: 80) |
| `--strict` | Exit non-zero on warnings as well as errors | | `--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: `layout` remains available as a compatibility alias for the same visual inspection pass:
+22
View File
@@ -175,11 +175,13 @@ interface GeometryFixture {
elementRect?: LayoutRect; elementRect?: LayoutRect;
time: number; time: number;
overflow?: LayoutOverflow; overflow?: LayoutOverflow;
dataAttributes?: Record<string, string>;
} }
function geometryCandidate(fixture: GeometryFixture) { function geometryCandidate(fixture: GeometryFixture) {
return { return {
...anchor(fixture.selector, fixture.time), ...anchor(fixture.selector, fixture.time),
...(fixture.dataAttributes ? { dataAttributes: fixture.dataAttributes } : {}),
kind: fixture.kind, kind: fixture.kind,
tag: fixture.tag, tag: fixture.tag,
text: fixture.text, 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); 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 () => { it("filters caption candidates by the element box while centering the text rect", async () => {
const collectGeometryCandidates = vi.fn(async (time: number) => [ const collectGeometryCandidates = vi.fn(async (time: number) => [
geometryCandidate({ geometryCandidate({
@@ -105,6 +105,10 @@
return !!element.closest("[data-layout-allow-overflow]"); return !!element.closest("[data-layout-allow-overflow]");
} }
function hasAllowCaptionZoneFlag(element) {
return !!element.closest("[data-layout-allow-caption-zone]");
}
function hasTextClipOptOut(element) { function hasTextClipOptOut(element) {
return hasAllowOverflowFlag(element) || element.hasAttribute("data-layout-bleed"); return hasAllowOverflowFlag(element) || element.hasAttribute("data-layout-bleed");
} }
@@ -1396,7 +1400,7 @@
} }
if (!isVisibleElement(element, 0.05, false)) continue; if (!isVisibleElement(element, 0.05, false)) continue;
const elementRect = toRect(element.getBoundingClientRect()); const elementRect = toRect(element.getBoundingClientRect());
if (includeText && hasOwnTextCandidate(element, true)) { if (includeText && hasOwnTextCandidate(element, true) && !hasAllowCaptionZoneFlag(element)) {
const rect = textRectFor(element, true); const rect = textRectFor(element, true);
if (rect) { if (rect) {
candidates.push( candidates.push(
@@ -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); expect(candidates.some((candidate) => candidate.selector === "#caption")).toBe(false);
}); });
it("excludes text marked data-layout-allow-caption-zone from geometry candidates", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<p id="copy">Main copy</p>
<p id="lower" data-layout-allow-caption-zone>Lower third</p>
<div data-layout-allow-caption-zone><span id="nested">Nested lower</span></div>
</div>
`;
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", () => { it("scans body-level composition siblings and includes a media boundary root", () => {
document.body.innerHTML = ` document.body.innerHTML = `
<canvas id="boundary" data-composition-id="background" data-width="640" data-height="360"></canvas> <canvas id="boundary" data-composition-id="background" data-width="640" data-height="360"></canvas>
+20 -8
View File
@@ -238,6 +238,21 @@ function geometryIssueAnchor(candidate: CheckGeometryCandidate, time: number) {
}; };
} }
function captionCenterInZone(
rect: CheckGeometryCandidate["rect"],
zone: NonNullable<CheckOptions["captionZone"]>,
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( function captionFinding(
candidate: CheckGeometryCandidate, candidate: CheckGeometryCandidate,
options: CheckOptions, options: CheckOptions,
@@ -246,13 +261,9 @@ function captionFinding(
): { key: string; issue: AnchoredLayoutIssue } | null { ): { key: string; issue: AnchoredLayoutIssue } | null {
const zone = options.captionZone; const zone = options.captionZone;
if (!zone || candidate.kind !== "text" || !candidateIsSized(candidate, canvas)) return null; if (!zone || candidate.kind !== "text" || !candidateIsSized(candidate, canvas)) return null;
const cx = candidate.rect.left + candidate.rect.width / 2; // Backstop for mocks/non-browser sources; browser already strips via closest() (own attrs only here).
const cy = candidate.rect.top + candidate.rect.height / 2; if ("data-layout-allow-caption-zone" in candidate.dataAttributes) return null;
const inside = const { inside, cy } = captionCenterInZone(candidate.rect, zone, canvas);
cx >= zone.x0 * canvas.width &&
cx <= zone.x1 * canvas.width &&
cy >= zone.y0 * canvas.height &&
cy <= zone.y1 * canvas.height;
if (!inside) return null; if (!inside) return null;
const text = candidate.text.slice(0, 48); const text = candidate.text.slice(0, 48);
const pctFromBottom = Math.round(((canvas.height - cy) / canvas.height) * 100); const pctFromBottom = Math.round(((canvas.height - cy) / canvas.height) * 100);
@@ -264,7 +275,8 @@ function captionFinding(
severity: zone.severity === "error" ? "error" : "warning", severity: zone.severity === "error" ? "error" : "warning",
text, text,
message: `<${candidate.tag}> "${text}" is centred in the reserved caption band (~${pctFromBottom}% up from the bottom).`, 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.",
}, },
}; };
} }
+2 -2
View File
@@ -26,11 +26,11 @@
"files": 121 "files": 121
}, },
"hyperframes-cli": { "hyperframes-cli": {
"hash": "8c791e330873b1bd", "hash": "966972db5ab8f932",
"files": 11 "files": 11
}, },
"hyperframes-core": { "hyperframes-core": {
"hash": "773fc6d15d9e87b3", "hash": "a270c70ced8cf952",
"files": 19 "files": 19
}, },
"hyperframes-creative": { "hyperframes-creative": {
@@ -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 `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. - 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. - 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). - 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 ## 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-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-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-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. - `data-layout-ignore` — decorative element that should never be audited.
**Opt-in pipeline gates** (used by orchestrators; off by default): **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 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`. **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`.
@@ -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. - 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. - **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-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 ## Legacy / Removed Attributes