diff --git a/.agents/skills/changelog-video/SKILL.md b/.agents/skills/changelog-video/SKILL.md
index 0bfd4f291..02c68de30 100644
--- a/.agents/skills/changelog-video/SKILL.md
+++ b/.agents/skills/changelog-video/SKILL.md
@@ -145,9 +145,10 @@ scaffold.
### 6 · Gates (all green before presenting)
-1. `bun run --cwd packages/cli hyperframes check` (or the installed
+1. `bun run --cwd packages/cli hyperframes check --caption-zone "x0=0;y0=.90;x1=1;y1=1;severity=error;seek=.02,.06,.10,.14,.18,.22,.26,.30,.34,.38,.42,.46,.50,.54,.58,.62,.66,.70,.74,.78,.82,.86,.90,.94,.98"` (or the installed
`hyperframes` CLI from the repo-local `skills/hyperframes-cli/` skill) —
- 0 errors (contrast: dim text ≥ .66 alpha). Do NOT reach for
+ 0 errors (contrast: dim text ≥ .66 alpha; scene content stays above the
+ caption rail). Do NOT reach for
`npx hyperframes@latest`; the tracked repo-local CLI is the source of
truth for the composition contract this skill produces against.
2. `seam-gate.mjs verify` — 0 fail.
diff --git a/.agents/skills/changelog-video/examples/master-skeleton.html b/.agents/skills/changelog-video/examples/master-skeleton.html
index 35a6f81c2..401b22285 100644
--- a/.agents/skills/changelog-video/examples/master-skeleton.html
+++ b/.agents/skills/changelog-video/examples/master-skeleton.html
@@ -48,7 +48,7 @@
HYPERFRAMES WEEKLY ·
-
+
@@ -57,8 +57,9 @@
-
+ done ≥0.45s before the next. Put data-layout-allow-overlap only on
+ the specific text blocks that deliberately layer. -->
+
01 ·
diff --git a/.agents/skills/changelog-video/references/build-spec.md b/.agents/skills/changelog-video/references/build-spec.md
index d376e7bbd..395d6bf8d 100644
--- a/.agents/skills/changelog-video/references/build-spec.md
+++ b/.agents/skills/changelog-video/references/build-spec.md
@@ -102,8 +102,8 @@ otherwise) and must stay flat 2D (no 3D ancestors).
## Lint/check gotchas (all hit before, all pre-solved)
- Mock containers with intentional stacking: `data-layout-allow-overlap` on
- the slide root; elements a playhead/line crosses:
- `data-layout-allow-occlusion`.
+ each text block that deliberately participates, never on the slide root;
+ elements a playhead/line crosses: `data-layout-allow-occlusion`.
- Dim text: `rgba(245,246,244,.66)` minimum (contrast gate).
- Audio: every `
` carries an `id`. BGM: the house track ships at
`/assets/bgm.mp3` (159s instrumental) — copy it to the project
diff --git a/.claude/skills/changelog-video/SKILL.md b/.claude/skills/changelog-video/SKILL.md
index 0bfd4f291..02c68de30 100644
--- a/.claude/skills/changelog-video/SKILL.md
+++ b/.claude/skills/changelog-video/SKILL.md
@@ -145,9 +145,10 @@ scaffold.
### 6 · Gates (all green before presenting)
-1. `bun run --cwd packages/cli hyperframes check` (or the installed
+1. `bun run --cwd packages/cli hyperframes check --caption-zone "x0=0;y0=.90;x1=1;y1=1;severity=error;seek=.02,.06,.10,.14,.18,.22,.26,.30,.34,.38,.42,.46,.50,.54,.58,.62,.66,.70,.74,.78,.82,.86,.90,.94,.98"` (or the installed
`hyperframes` CLI from the repo-local `skills/hyperframes-cli/` skill) —
- 0 errors (contrast: dim text ≥ .66 alpha). Do NOT reach for
+ 0 errors (contrast: dim text ≥ .66 alpha; scene content stays above the
+ caption rail). Do NOT reach for
`npx hyperframes@latest`; the tracked repo-local CLI is the source of
truth for the composition contract this skill produces against.
2. `seam-gate.mjs verify` — 0 fail.
diff --git a/.claude/skills/changelog-video/examples/master-skeleton.html b/.claude/skills/changelog-video/examples/master-skeleton.html
index 35a6f81c2..401b22285 100644
--- a/.claude/skills/changelog-video/examples/master-skeleton.html
+++ b/.claude/skills/changelog-video/examples/master-skeleton.html
@@ -48,7 +48,7 @@
HYPERFRAMES WEEKLY ·
-
+
@@ -57,8 +57,9 @@
-
+ done ≥0.45s before the next. Put data-layout-allow-overlap only on
+ the specific text blocks that deliberately layer. -->
+
01 ·
diff --git a/.claude/skills/changelog-video/references/build-spec.md b/.claude/skills/changelog-video/references/build-spec.md
index d376e7bbd..395d6bf8d 100644
--- a/.claude/skills/changelog-video/references/build-spec.md
+++ b/.claude/skills/changelog-video/references/build-spec.md
@@ -102,8 +102,8 @@ otherwise) and must stay flat 2D (no 3D ancestors).
## Lint/check gotchas (all hit before, all pre-solved)
- Mock containers with intentional stacking: `data-layout-allow-overlap` on
- the slide root; elements a playhead/line crosses:
- `data-layout-allow-occlusion`.
+ each text block that deliberately participates, never on the slide root;
+ elements a playhead/line crosses: `data-layout-allow-occlusion`.
- Dim text: `rgba(245,246,244,.66)` minimum (contrast gate).
- Audio: every `
` carries an `id`. BGM: the house track ships at
`/assets/bgm.mp3` (159s instrumental) — copy it to the project
diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx
index 1e9276f12..510902927 100644
--- a/docs/packages/cli.mdx
+++ b/docs/packages/cli.mdx
@@ -646,7 +646,10 @@ When the audit is wrong because the layout is deliberate, say so in the HTML and
re-run. Put `data-layout-allow-overflow` on a planned off-canvas entrance,
`data-layout-allow-overlap` on text you meant to stack, `data-layout-allow-occlusion`
on text you meant to hide behind a prop, and `data-layout-ignore` on decoration
-that should not be audited at all. Each is inherited, so an ancestor works.
+that should not be audited at all. Overflow, occlusion, and ignore markers are
+inherited, so an ancestor works. The overlap marker is intentionally local: put it
+on the specific text block participating in the layering, never on a scene/root
+wrapper, so unrelated descendant collisions remain auditable.
For deliberate lower-third copy under `--caption-zone`, use
`data-layout-allow-caption-zone`. It silences `caption_zone_collision` and
diff --git a/docs/prompting/rules-and-anti-patterns.mdx b/docs/prompting/rules-and-anti-patterns.mdx
index a022acf55..811dcfdba 100644
--- a/docs/prompting/rules-and-anti-patterns.mdx
+++ b/docs/prompting/rules-and-anti-patterns.mdx
@@ -64,6 +64,9 @@ legitimate — a caption designed to sit behind a matted subject needs one.
Two things about them are worth knowing before you reach for either:
+- **`data-layout-allow-overlap` is local to the marked text block.** Do not put it on a
+ scene/root wrapper to waive a whole mock slide. Mark only the text that deliberately
+ participates in the layering; every unrelated descendant collision remains auditable.
- **`data-layout-allow-occlusion` also silences the WCAG contrast gate for that whole
subtree.** Validating this guide's confetti example, moving the attribute onto a cluster
root took contrast coverage from 73 checks to 13 — and a deliberately near-invisible
diff --git a/packages/cli/src/commands/changelogSkillContent.test.ts b/packages/cli/src/commands/changelogSkillContent.test.ts
new file mode 100644
index 000000000..7d3fc2aea
--- /dev/null
+++ b/packages/cli/src/commands/changelogSkillContent.test.ts
@@ -0,0 +1,30 @@
+// @vitest-environment node
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+const REPO_ROOT = join(fileURLToPath(new URL(".", import.meta.url)), "..", "..", "..", "..");
+const read = (...parts: string[]): string => readFileSync(join(REPO_ROOT, ...parts), "utf8");
+
+describe("changelog-video layout contract", () => {
+ const buildSpec = read(".claude", "skills", "changelog-video", "references", "build-spec.md");
+ const skill = read(".claude", "skills", "changelog-video", "SKILL.md");
+ const skeleton = read(".claude", "skills", "changelog-video", "examples", "master-skeleton.html");
+
+ it("keeps overlap waivers local instead of disabling a whole slide audit", () => {
+ expect(buildSpec).not.toMatch(/data-layout-allow-overlap` on\s+the slide root/);
+ expect(buildSpec).toContain("never on the slide root");
+ expect(skeleton).not.toMatch(/class="slide[^"]*"[^>]*data-layout-allow-overlap/);
+ });
+
+ it("checks scene content against the caption rail while exempting the rail itself", () => {
+ const seekList = /--caption-zone "[^"]*seek=([.\d,]+)"/.exec(skill)?.[1];
+ const seeks = seekList?.split(",").map(Number) ?? [];
+ expect(seeks).toHaveLength(25);
+ expect(
+ Math.max(...seeks.slice(1).map((seek, index) => seek - (seeks[index] ?? Number.NaN))) * 60,
+ ).toBeLessThanOrEqual(2.401);
+ expect(skeleton).toMatch(/id="cap-line"[^>]*data-layout-allow-caption-zone/);
+ });
+});
diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts
index 6ea45395d..c48f0aa81 100644
--- a/packages/cli/src/commands/check.test.ts
+++ b/packages/cli/src/commands/check.test.ts
@@ -530,6 +530,49 @@ it("skips caption_zone_collision when data-layout-allow-caption-zone is set", as
expect(report.layout.findings).toEqual([]);
});
+it("keeps overlap waivers from suppressing changelog caption-rail collisions", async () => {
+ const collectGeometryCandidates = vi.fn(async (time: number) => [
+ geometryCandidate({
+ kind: "text",
+ tag: "div",
+ text: "Release card copy",
+ selector: "#release-card",
+ rect: fixtureRect(120, 970, 840, 118),
+ time,
+ dataAttributes: { "data-layout-allow-overlap": "" },
+ }),
+ geometryCandidate({
+ kind: "text",
+ tag: "div",
+ text: "Intentional caption rail",
+ selector: "#cap-line",
+ rect: fixtureRect(0, 990, 1080, 52),
+ time,
+ dataAttributes: { "data-layout-allow-caption-zone": "" },
+ }),
+ ]);
+ const { report } = await runScenario(
+ fakeDriver({
+ getCanvas: vi.fn(async () => ({ width: 1080, height: 1080 })),
+ collectGeometryCandidates,
+ }),
+ {
+ samples: 1,
+ contrast: false,
+ captionZone: { x0: 0, y0: 0.9, x1: 1, y1: 1, severity: "error" },
+ },
+ );
+
+ expect(report.layout.findings).toEqual([
+ expect.objectContaining({
+ code: "caption_zone_collision",
+ severity: "error",
+ selector: "#release-card",
+ }),
+ ]);
+ expect(report.ok).toBe(false);
+});
+
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 8c86391cc..c3b8950e1 100644
--- a/packages/cli/src/commands/layout-audit.browser.js
+++ b/packages/cli/src/commands/layout-audit.browser.js
@@ -545,7 +545,10 @@
}
function hasAllowOverlapFlag(element) {
- return !!element.closest("[data-layout-allow-overlap]");
+ // Overlap intent belongs to the text block that participates in the
+ // layering. Inheriting this marker from a scene/root would turn one local
+ // waiver into a blanket exemption for every collision in that subtree.
+ return element.hasAttribute("data-layout-allow-overlap");
}
function isTransparentColor(color) {
diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts
index fbc527f87..30bea7fa7 100644
--- a/packages/cli/src/commands/layout-audit.browser.test.ts
+++ b/packages/cli/src/commands/layout-audit.browser.test.ts
@@ -1195,6 +1195,16 @@ describe("layout-audit.browser content overlap", () => {
expectExemptFromOverlap({ attrs: "data-layout-allow-overlap" });
});
+ it("does not let a parent allow-overlap marker disable every descendant collision", () => {
+ const issues = auditOverlapScene({
+ rootAttrs: "data-layout-allow-overlap",
+ a: { textRect: rect({ left: 100, top: 100, width: 400, height: 100 }) },
+ b: { textRect: rect({ left: 300, top: 120, width: 400, height: 100 }) },
+ });
+
+ expect(issues.some((issue) => issue.code === "content_overlap")).toBe(true);
+ });
+
// A typewriter span clipped to nothing (clip-path: inset(0 100% 0 0)) keeps a
// normal box but paints zero pixels; overlapping it must not flag the visible
// block beneath. The clipped element is unreachable by elementFromPoint, which
@@ -1609,11 +1619,12 @@ function expectExemptFromOverlap(aOverrides: { color?: string; attrs?: string })
}
function auditOverlapScene(options: {
+ rootAttrs?: string;
a: { textRect: DOMRect | DOMRect[]; color?: string; attrs?: string; clipPath?: string };
b: { textRect: DOMRect | DOMRect[]; color?: string; attrs?: string; clipPath?: string };
}): ReturnType {
document.body.innerHTML = `
-
+
Block A copy
Block B copy
@@ -1631,6 +1642,16 @@ function auditOverlapScene(options: {
b: normalizeTextRects(options.b.textRect),
};
+ installOverlapStyles(colors, clipPaths);
+ installOverlapGeometry(textRects);
+ installAuditScript();
+ return runAudit();
+}
+
+function installOverlapStyles(
+ colors: Record
,
+ clipPaths: Record,
+): void {
vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
const id = (element as Element).id;
return {
@@ -1649,7 +1670,9 @@ function auditOverlapScene(options: {
if (!isFullyClipped(clipPaths.a ?? "none")) return document.getElementById("a");
return null;
};
+}
+function installOverlapGeometry(textRects: Record): void {
for (const element of Array.from(document.querySelectorAll("*"))) {
vi.spyOn(element, "getBoundingClientRect").mockReturnValue(
boundingTextRect(textRects[element.id]) ??
@@ -1674,9 +1697,6 @@ function auditOverlapScene(options: {
detach() {},
} as unknown as Range;
});
-
- installAuditScript();
- return runAudit();
}
function normalizeTextRects(value: DOMRect | DOMRect[]): DOMRect[] {
diff --git a/skills-manifest.json b/skills-manifest.json
index 6ec003425..2746a31bd 100644
--- a/skills-manifest.json
+++ b/skills-manifest.json
@@ -30,7 +30,7 @@
"files": 7
},
"hyperframes-cli": {
- "hash": "3fa884269c43d7df",
+ "hash": "5d02a1713635e7e7",
"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 1420306af..c08cd91bb 100644
--- a/skills/hyperframes-cli/references/lint-validate-inspect.md
+++ b/skills/hyperframes-cli/references/lint-validate-inspect.md
@@ -55,7 +55,7 @@ Every finding carries a selector, the element's `data-*` identity, the compositi
**Escape hatches** (mark intent in the HTML, then re-run):
- `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). Applies only to the marked text block; it is not inherited. Mark the specific layering participant, never a scene/root wrapper, so unrelated descendant collisions remain auditable.
- `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.