Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ npx hyperframes <command>
- 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`)

Expand Down Expand Up @@ -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 |
|------|-------------|
Expand All @@ -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:

Expand Down
86 changes: 86 additions & 0 deletions packages/cli/src/commands/layout-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -418,6 +503,7 @@
}

issues.push(...containerOverflowIssues(root, time, tolerance));
issues.push(...contentOverlapIssues(root, time));
return issues;
};
})();
94 changes: 94 additions & 0 deletions packages/cli/src/commands/layout-audit.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof runAudit> {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="a" ${options.a.attrs ?? ""}>Block A copy</div>
<div id="b" ${options.b.attrs ?? ""}>Block B copy</div>
</div>
`;
const colors: Record<string, string> = {
a: options.a.color ?? "rgb(0, 0, 0)",
b: options.b.color ?? "rgb(0, 0, 0)",
};
const textRects: Record<string, DOMRect> = { 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);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/utils/layoutAudit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
Loading