fix(cli): keep overlap waivers local to marked text (#3464)

* fix(cli): scope overlap waiver to marked text

* fix(skills): guard changelog caption rail

* fix(skills): densify changelog caption checks

* test(skills): satisfy strict seek typing
This commit is contained in:
Miguel Ángel
2026-08-24 14:22:03 -04:00
committed by GitHub
parent 95e1ac9f04
commit e5a5e6b151
14 changed files with 128 additions and 22 deletions
+3 -2
View File
@@ -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.
@@ -48,7 +48,7 @@
<div id="bg-scrim"></div>
<div id="kicker-chip" class="chrome">HYPERFRAMES WEEKLY <span style="color:rgba(245,246,244,.66)">· <RANGE></span></div>
<div id="progress" class="chrome"><span class="dot" id="dot1"></span><!-- one per theme --></div>
<div id="cap-line" class="chrome" data-layout-allow-overlap></div>
<div id="cap-line" class="chrome" data-layout-allow-caption-zone></div>
<!-- Title (film open — authors its OWN entry; exit is stamped) -->
<div id="s-title" class="slide clip" data-start="0" data-duration="<CUT1>" data-track-index="1">
@@ -57,8 +57,9 @@
<!-- One slide per theme. data-start = EXACTLY the ledger cut time.
Shell composed at local t=0; internal beats ≥0.4s after the cut,
done ≥0.45s before the next. data-layout-allow-overlap on mock slides. -->
<div id="s-t1" class="slide clip" data-start="<CUT1>" data-duration="<DUR1>" data-track-index="1" data-layout-allow-overlap>
done ≥0.45s before the next. Put data-layout-allow-overlap only on
the specific text blocks that deliberately layer. -->
<div id="s-t1" class="slide clip" data-start="<CUT1>" data-duration="<DUR1>" data-track-index="1">
<div class="sec-chip">01 · <THEME></div>
<div class="sec-head"><HEADLINE></div>
<!-- the mock, from references/visualization-registry.md, y ∈ [288, 944] -->
@@ -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 `<audio>` carries an `id`. BGM: the house track ships at
`<SKILL_DIR>/assets/bgm.mp3` (159s instrumental) — copy it to the project
+3 -2
View File
@@ -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.
@@ -48,7 +48,7 @@
<div id="bg-scrim"></div>
<div id="kicker-chip" class="chrome">HYPERFRAMES WEEKLY <span style="color:rgba(245,246,244,.66)">· <RANGE></span></div>
<div id="progress" class="chrome"><span class="dot" id="dot1"></span><!-- one per theme --></div>
<div id="cap-line" class="chrome" data-layout-allow-overlap></div>
<div id="cap-line" class="chrome" data-layout-allow-caption-zone></div>
<!-- Title (film open — authors its OWN entry; exit is stamped) -->
<div id="s-title" class="slide clip" data-start="0" data-duration="<CUT1>" data-track-index="1">
@@ -57,8 +57,9 @@
<!-- One slide per theme. data-start = EXACTLY the ledger cut time.
Shell composed at local t=0; internal beats ≥0.4s after the cut,
done ≥0.45s before the next. data-layout-allow-overlap on mock slides. -->
<div id="s-t1" class="slide clip" data-start="<CUT1>" data-duration="<DUR1>" data-track-index="1" data-layout-allow-overlap>
done ≥0.45s before the next. Put data-layout-allow-overlap only on
the specific text blocks that deliberately layer. -->
<div id="s-t1" class="slide clip" data-start="<CUT1>" data-duration="<DUR1>" data-track-index="1">
<div class="sec-chip">01 · <THEME></div>
<div class="sec-head"><HEADLINE></div>
<!-- the mock, from references/visualization-registry.md, y ∈ [288, 944] -->
@@ -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 `<audio>` carries an `id`. BGM: the house track ships at
`<SKILL_DIR>/assets/bgm.mp3` (159s instrumental) — copy it to the project
+4 -1
View File
@@ -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
@@ -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
@@ -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/);
});
});
+43
View File
@@ -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({
@@ -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) {
@@ -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<typeof runAudit> {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="root" data-composition-id="main" data-width="1920" data-height="1080" ${options.rootAttrs ?? ""}>
<div id="a" ${options.a.attrs ?? ""}>Block A copy</div>
<div id="b" ${options.b.attrs ?? ""}>Block B copy</div>
</div>
@@ -1631,6 +1642,16 @@ function auditOverlapScene(options: {
b: normalizeTextRects(options.b.textRect),
};
installOverlapStyles(colors, clipPaths);
installOverlapGeometry(textRects);
installAuditScript();
return runAudit();
}
function installOverlapStyles(
colors: Record<string, string>,
clipPaths: Record<string, string>,
): 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<string, DOMRect[]>): 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[] {
+1 -1
View File
@@ -30,7 +30,7 @@
"files": 7
},
"hyperframes-cli": {
"hash": "3fa884269c43d7df",
"hash": "5d02a1713635e7e7",
"files": 11
},
"hyperframes-core": {
@@ -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.