Skip to content

Commit 37c5ea8

Browse files
xuanrulicursoragent
andcommitted
feat(check): add data-layout-allow-caption-zone waiver
Opt intentional lower-third copy out of caption_zone_collision. Co-authored-by: Cursor <[email protected]>
1 parent 8800214 commit 37c5ea8

7 files changed

Lines changed: 70 additions & 11 deletions

File tree

‎docs/packages/cli.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,7 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o
612612
| `--max-issues` | Maximum findings to print or return after static collapse (default: 80) |
613613
| `--strict` | Exit non-zero on warnings as well as errors |
614614

615-
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).
615+
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-caption-zone` when main text is intentionally centered in the `--caption-zone` band.
616616

617617
`layout` remains available as a compatibility alias for the same visual inspection pass:
618618

‎packages/cli/src/commands/check.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,11 +175,13 @@ interface GeometryFixture {
175175
elementRect?: LayoutRect;
176176
time: number;
177177
overflow?: LayoutOverflow;
178+
dataAttributes?: Record<string, string>;
178179
}
179180

180181
function geometryCandidate(fixture: GeometryFixture) {
181182
return {
182183
...anchor(fixture.selector, fixture.time),
184+
...(fixture.dataAttributes ? { dataAttributes: fixture.dataAttributes } : {}),
183185
kind: fixture.kind,
184186
tag: fixture.tag,
185187
text: fixture.text,
@@ -446,6 +448,26 @@ it("flags only text whose center is inside the caption band at the default end s
446448
expect(report.ok).toBe(true);
447449
});
448450

451+
it("skips caption_zone_collision when data-layout-allow-caption-zone is set", async () => {
452+
const collectGeometryCandidates = vi.fn(async (time: number) => [
453+
geometryCandidate({
454+
kind: "text",
455+
tag: "div",
456+
text: "Intentional lower third",
457+
selector: "#lower-third",
458+
rect: fixtureRect(860, 870, 200, 60),
459+
time,
460+
dataAttributes: { "data-layout-allow-caption-zone": "" },
461+
}),
462+
]);
463+
const { report } = await runScenario(
464+
fakeDriver({ getDuration: vi.fn(async () => 10), collectGeometryCandidates }),
465+
{ samples: 1, contrast: false, captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 0.9 } },
466+
);
467+
468+
expect(report.layout.findings).toEqual([]);
469+
});
470+
449471
it("filters caption candidates by the element box while centering the text rect", async () => {
450472
const collectGeometryCandidates = vi.fn(async (time: number) => [
451473
geometryCandidate({

‎packages/cli/src/commands/layout-audit.browser.js‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1396,7 +1396,11 @@
13961396
}
13971397
if (!isVisibleElement(element, 0.05, false)) continue;
13981398
const elementRect = toRect(element.getBoundingClientRect());
1399-
if (includeText && hasOwnTextCandidate(element, true)) {
1399+
if (
1400+
includeText &&
1401+
hasOwnTextCandidate(element, true) &&
1402+
!element.closest("[data-layout-allow-caption-zone]")
1403+
) {
14001404
const rect = textRectFor(element, true);
14011405
if (rect) {
14021406
candidates.push(

‎packages/cli/src/commands/layout-audit.browser.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,27 @@ it("returns own-text rects and media overflow while excluding caption layers", (
381381
expect(candidates.some((candidate) => candidate.selector === "#caption")).toBe(false);
382382
});
383383

384+
it("excludes text marked data-layout-allow-caption-zone from geometry candidates", () => {
385+
document.body.innerHTML = `
386+
<div id="root" data-composition-id="main" data-width="640" data-height="360">
387+
<p id="copy">Main copy</p>
388+
<p id="lower" data-layout-allow-caption-zone>Lower third</p>
389+
<div data-layout-allow-caption-zone><span id="nested">Nested lower</span></div>
390+
</div>
391+
`;
392+
installGeometry({
393+
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
394+
copy: rect({ left: 100, top: 100, width: 200, height: 40 }),
395+
lower: rect({ left: 100, top: 280, width: 200, height: 40 }),
396+
nested: rect({ left: 100, top: 300, width: 200, height: 40 }),
397+
text: rect({ left: 100, top: 100, width: 200, height: 40 }),
398+
});
399+
installAuditScript();
400+
401+
const candidates = runGeometryCandidates({ text: true, media: false, tolerance: 2 });
402+
expect(candidates.map((candidate) => candidate.selector)).toEqual(["#copy"]);
403+
});
404+
384405
it("scans body-level composition siblings and includes a media boundary root", () => {
385406
document.body.innerHTML = `
386407
<canvas id="boundary" data-composition-id="background" data-width="640" data-height="360"></canvas>

‎packages/cli/src/utils/checkPipeline.ts‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,21 @@ function geometryIssueAnchor(candidate: CheckGeometryCandidate, time: number) {
238238
};
239239
}
240240

241+
function captionCenterInZone(
242+
rect: CheckGeometryCandidate["rect"],
243+
zone: NonNullable<CheckOptions["captionZone"]>,
244+
canvas: Canvas,
245+
): { inside: boolean; cy: number } {
246+
const cx = rect.left + rect.width / 2;
247+
const cy = rect.top + rect.height / 2;
248+
const inside =
249+
cx >= zone.x0 * canvas.width &&
250+
cx <= zone.x1 * canvas.width &&
251+
cy >= zone.y0 * canvas.height &&
252+
cy <= zone.y1 * canvas.height;
253+
return { inside, cy };
254+
}
255+
241256
function captionFinding(
242257
candidate: CheckGeometryCandidate,
243258
options: CheckOptions,
@@ -246,13 +261,8 @@ function captionFinding(
246261
): { key: string; issue: AnchoredLayoutIssue } | null {
247262
const zone = options.captionZone;
248263
if (!zone || candidate.kind !== "text" || !candidateIsSized(candidate, canvas)) return null;
249-
const cx = candidate.rect.left + candidate.rect.width / 2;
250-
const cy = candidate.rect.top + candidate.rect.height / 2;
251-
const inside =
252-
cx >= zone.x0 * canvas.width &&
253-
cx <= zone.x1 * canvas.width &&
254-
cy >= zone.y0 * canvas.height &&
255-
cy <= zone.y1 * canvas.height;
264+
if ("data-layout-allow-caption-zone" in candidate.dataAttributes) return null;
265+
const { inside, cy } = captionCenterInZone(candidate.rect, zone, canvas);
256266
if (!inside) return null;
257267
const text = candidate.text.slice(0, 48);
258268
const pctFromBottom = Math.round(((canvas.height - cy) / canvas.height) * 100);
@@ -264,7 +274,8 @@ function captionFinding(
264274
severity: zone.severity === "error" ? "error" : "warning",
265275
text,
266276
message: `<${candidate.tag}> "${text}" is centred in the reserved caption band (~${pctFromBottom}% up from the bottom).`,
267-
fixHint: "Keep main content outside the configured caption band.",
277+
fixHint:
278+
"Keep main content outside the configured caption band, or mark intentional lower-third copy with data-layout-allow-caption-zone.",
268279
},
269280
};
270281
}

‎skills-manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"files": 11
3131
},
3232
"hyperframes-core": {
33-
"hash": "773fc6d15d9e87b3",
33+
"hash": "e306b82da6279994",
3434
"files": 19
3535
},
3636
"hyperframes-creative": {

‎skills/hyperframes-core/references/data-attributes.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ See `sub-compositions.md` for the full wiring pattern.
6161
- 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.
6262
- **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.
6363
- `data-layout-ignore` — exclude this element from layout audits entirely.
64+
- `data-layout-allow-caption-zone` — opt out of `--caption-zone` / `caption_zone_collision` for intentional lower-third copy (element or ancestor).
6465

6566
## Legacy / Removed Attributes
6667

0 commit comments

Comments
 (0)