diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 551693b5b..83a5b6aa6 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -19,7 +19,7 @@ npx hyperframes - Preview compositions with live hot reload (`preview`) - Render compositions to MP4 locally or in Docker (`render`) - Lint compositions for structural issues (`lint`) -- Inspect rendered visual layout for text overflow and clipped containers (`inspect`) +- Inspect rendered visual layout for text overflow, clipped containers, and overlapping text (`inspect`) - Capture key frames as PNG screenshots (`snapshot`) - Check your environment for missing dependencies (`doctor`) @@ -532,7 +532,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ ◇ 1 error(s), 0 warning(s), 0 info(s) ``` - `inspect` bundles the project, serves it locally, opens headless Chrome, seeks through the composition, and reports text or elements that escape their intended boxes. It is designed for agent workflows: each finding includes a schema version, timestamp or collapsed timestamp range, selector, nearest container selector, measured bounding boxes, overflow sides, and a fix hint. + `inspect` bundles the project, serves it locally, opens headless Chrome, seeks through the composition, and reports text or elements that escape their intended boxes, plus pairs of text blocks that overlap each other (`content_overlap`). It is designed for agent workflows: each finding includes a schema version, timestamp or collapsed timestamp range, selector, nearest container selector, measured bounding boxes, overflow sides, and a fix hint. | Flag | Description | |------|-------------| @@ -545,7 +545,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ | `--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-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). `layout` remains available as a compatibility alias for the same visual inspection pass: diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index aebcfdfac..3ce424d53 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -398,6 +398,91 @@ return issues; } + function hasAllowOverlapFlag(element) { + return !!element.closest("[data-layout-allow-overlap]"); + } + + function alphaFromParts(parts, index) { + return parts.length > index ? parsePx(parts[index]) : 1; + } + + // Alpha of a CSS colour; 1 when no alpha component is present. Handles both + // legacy `rgba(r, g, b, a)` and modern `rgb(r g b / a)` syntaxes. + function colorAlpha(color) { + const match = (color || "").match(/rgba?\(([^)]+)\)/); + if (!match) return 1; + const body = match[1]; + return body.includes(",") + ? alphaFromParts(body.split(","), 3) + : alphaFromParts(body.split("/"), 1); + } + + // A text block competes for space only when it is solid: watermark-style text + // (low colour alpha) is decorative and exempt, as are elements opted out with + // data-layout-allow-overlap. + function isSolidTextBlock(element) { + if (!isVisibleElement(element) || !hasOwnTextCandidate(element)) return false; + if (hasAllowOverlapFlag(element)) return false; + return colorAlpha(getComputedStyle(element).color) >= 0.35; + } + + function collectSolidTextBlocks(root) { + const blocks = []; + for (const element of Array.from(root.querySelectorAll("*"))) { + if (!isSolidTextBlock(element)) continue; + const rect = textRectFor(element); + if (rect) blocks.push({ element, rect }); + } + return blocks; + } + + function rectArea(rect) { + return rect.width * rect.height; + } + + function intersectionArea(a, b) { + const overlapX = Math.min(a.right, b.right) - Math.max(a.left, b.left); + const overlapY = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top); + return overlapX > 0 && overlapY > 0 ? overlapX * overlapY : 0; + } + + function isNested(a, b) { + return a.contains(b) || b.contains(a); + } + + // Two solid text blocks whose boxes overlap by more than a fifth of the + // smaller block read as a collision — unreadable, and invisible to the + // overflow checks, which only compare an element against its container. + function overlapIssue(a, b, time) { + if (isNested(a.element, b.element)) return null; + const area = intersectionArea(a.rect, b.rect); + if (area <= Math.min(rectArea(a.rect), rectArea(b.rect)) * 0.2) return null; + return { + code: "content_overlap", + severity: "error", + time, + selector: selectorFor(a.element), + containerSelector: selectorFor(b.element), + text: textContentFor(a.element), + message: "Two text blocks overlap and render unreadable.", + rect: a.rect, + fixHint: + "Give each block its own zone, or mark intentional layering with data-layout-allow-overlap.", + }; + } + + function contentOverlapIssues(root, time) { + const blocks = collectSolidTextBlocks(root); + const issues = []; + for (let i = 0; i < blocks.length; i++) { + for (let j = i + 1; j < blocks.length; j++) { + const issue = overlapIssue(blocks[i], blocks[j], time); + if (issue) issues.push(issue); + } + } + return issues; + } + window.__hyperframesLayoutAudit = function auditLayout(options) { const time = options && typeof options.time === "number" ? options.time : 0; const tolerance = @@ -418,6 +503,7 @@ } issues.push(...containerOverflowIssues(root, time, tolerance)); + issues.push(...contentOverlapIssues(root, time)); return issues; }; })(); diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 981c052df..00bd35614 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -100,6 +100,100 @@ describe("layout-audit.browser", () => { }); }); +describe("layout-audit.browser content overlap", () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; + delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit; + }); + + it("flags two solid text blocks that overlap", () => { + const overlap = auditOverlapScene({ + a: { textRect: rect({ left: 100, top: 100, width: 400, height: 100 }) }, + b: { textRect: rect({ left: 300, top: 120, width: 400, height: 100 }) }, + }).find((issue) => issue.code === "content_overlap"); + expect(overlap).toMatchObject({ selector: "#a", containerSelector: "#b" }); + }); + + it("ignores blocks that overlap by less than a fifth of the smaller box", () => { + const issues = auditOverlapScene({ + a: { textRect: rect({ left: 100, top: 100, width: 400, height: 100 }) }, + b: { textRect: rect({ left: 490, top: 100, width: 400, height: 100 }) }, + }); + expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false); + }); + + it("ignores watermark-style text with low colour alpha", () => { + expectExemptFromOverlap({ color: "rgba(0, 0, 0, 0.2)" }); + }); + + it("respects the data-layout-allow-overlap opt-out", () => { + expectExemptFromOverlap({ attrs: "data-layout-allow-overlap" }); + }); +}); + +// Both blocks overlap heavily; only the exemption on block A should suppress +// the finding, so a missing exemption would surface as a failure here. +function expectExemptFromOverlap(aOverrides: { color?: string; attrs?: string }): void { + const issues = auditOverlapScene({ + a: { textRect: rect({ left: 100, top: 100, width: 400, height: 100 }), ...aOverrides }, + b: { textRect: rect({ left: 300, top: 120, width: 400, height: 100 }) }, + }); + expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false); +} + +function auditOverlapScene(options: { + a: { textRect: DOMRect; color?: string; attrs?: string }; + b: { textRect: DOMRect; color?: string; attrs?: string }; +}): ReturnType { + document.body.innerHTML = ` +
+
Block A copy
+
Block B copy
+
+ `; + const colors: Record = { + a: options.a.color ?? "rgb(0, 0, 0)", + b: options.b.color ?? "rgb(0, 0, 0)", + }; + const textRects: Record = { a: options.a.textRect, b: options.b.textRect }; + + vi.spyOn(window, "getComputedStyle").mockImplementation((element) => { + const id = (element as Element).id; + return { + display: "block", + visibility: "visible", + opacity: "1", + color: colors[id] ?? "rgb(0, 0, 0)", + } as unknown as CSSStyleDeclaration; + }); + + for (const element of Array.from(document.querySelectorAll("*"))) { + vi.spyOn(element, "getBoundingClientRect").mockReturnValue( + textRects[element.id] ?? rect({ left: 0, top: 0, width: 1920, height: 1080 }), + ); + } + + vi.spyOn(document, "createRange").mockImplementation(() => { + let selected: Node | null = null; + return { + selectNodeContents(node: Node) { + selected = node; + }, + getClientRects() { + const id = (selected as Element | null)?.id ?? ""; + return textRects[id] + ? ([textRects[id]] as unknown as DOMRectList) + : ([] as unknown as DOMRectList); + }, + detach() {}, + } as unknown as Range; + }); + + installAuditScript(); + return runAudit(); +} + function installAuditScript(): void { window.eval(script); } diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index f02e8211b..19d89af5c 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -13,7 +13,8 @@ export type LayoutIssueCode = | "text_box_overflow" | "clipped_text" | "canvas_overflow" - | "container_overflow"; + | "container_overflow" + | "content_overlap"; export type LayoutIssueSeverity = "error" | "warning" | "info";