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
5 changes: 5 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
// Built as standalone IIFE for the browser-side sandbox runtime;
// referenced by file path (not import) in build-hyperframes-runtime-artifact.ts.
"packages/core/src/runtime/entry.ts",
// In-page audit scripts read as raw strings and injected via
// page.addScriptTag (see layout.ts / validate.ts) — referenced by file
// path, never imported, so they have no import-graph referrer.
"packages/cli/src/commands/layout-audit.browser.js",
"packages/cli/src/commands/contrast-audit.browser.js",
// Worker entry points loaded dynamically by their *Pool.ts companions.
"packages/producer/src/services/pngDecodeBlitWorker.ts",
"packages/producer/src/services/shaderTransitionWorker.ts",
Expand Down
4 changes: 2 additions & 2 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
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 text that is hidden beneath an opaque element (`text_occluded`). 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-occlusion` when text is intentionally layered beneath another element (for example a caption behind a foreground prop).

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

Expand Down
96 changes: 96 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,100 @@
return issues;
}

function isTransparentColor(color) {
return (
!color || color === "transparent" || color === "rgba(0, 0, 0, 0)" || color.endsWith(", 0)")
);
}

function alphaFromParts(parts, index) {
return parts.length > index ? parsePx(parts[index]) : 1;
}

function colorAlpha(color) {
const match = color.match(/rgba?\(([^)]+)\)/);
if (!match) return 1;
// Legacy `rgba(r, g, b, a)` keeps alpha as the 4th comma part; modern
// `rgb(r g b / a)` puts it after a slash. No alpha component → opaque.
const body = match[1];
return body.includes(",")
? alphaFromParts(body.split(","), 3)
: alphaFromParts(body.split("/"), 1);
}

function hasOpaqueBackground(style) {
if (style.backgroundImage && style.backgroundImage !== "none") return true;
if (isTransparentColor(style.backgroundColor)) return false;
return colorAlpha(style.backgroundColor) > 0.6;
}

const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]);

// An element hides text beneath it when it paints opaque pixels at near-full
// opacity: raster content (img/video/canvas), a background image, or a solid
// background colour. Low-opacity overlays (grain, scrims) do not occlude.
function isOpaqueOccluder(element) {
if (opacityChain(element) < 0.6) return false;
if (IGNORE_TAGS.has(element.tagName)) return false;
if (RASTER_TAGS.has(element.tagName)) return true;
return hasOpaqueBackground(getComputedStyle(element));
}

function hasAllowOcclusionFlag(element) {
return !!element.closest("[data-layout-allow-occlusion]");
}

// A foreign element is one painted independently of the text — not the text
// itself, its own subtree, or an ancestor it shares a background with.
function isForeignElement(element, hit) {
return !!hit && hit !== element && !element.contains(hit) && !hit.contains(element);
}

// The opaque element painted over (x, y), or null when the topmost element
// there is related to the text or non-opaque.
function occluderAt(element, x, y) {
if (typeof document.elementFromPoint !== "function") return null;
const hit = document.elementFromPoint(x, y);
if (!isForeignElement(element, hit)) return null;
return isOpaqueOccluder(hit) ? hit : null;
}

// Sweep a grid across the text box (three rows, not just the mid-line, so
// overlays covering only part of a multi-line block are caught) and return
// the first opaque element painted over any sample point.
function firstOccluder(element, textRect) {
for (const yFraction of [0.25, 0.5, 0.75]) {
const y = textRect.top + textRect.height * yFraction;
for (const xFraction of [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97]) {
const occluder = occluderAt(element, textRect.left + textRect.width * xFraction, y);
if (occluder) return occluder;
}
}
return null;
}

// Catches the blind spot the overflow checks miss: text that fits its box
// perfectly but is covered by a later sibling/overlay.
function occludedTextIssue(element, time) {
if (hasAllowOcclusionFlag(element)) return null;
const textRect = textRectFor(element);
if (!textRect) return null;
const occluder = firstOccluder(element, textRect);
if (!occluder) return null;
return {
code: "text_occluded",
severity: "error",
time,
selector: selectorFor(element),
containerSelector: selectorFor(occluder),
text: textContentFor(element),
message: "Text is hidden beneath an opaque element.",
rect: textRect,
fixHint:
"Give the text its own zone, raise its stacking order above the covering element, or mark intentional layering with data-layout-allow-occlusion.",
};
}

window.__hyperframesLayoutAudit = function auditLayout(options) {
const time = options && typeof options.time === "number" ? options.time : 0;
const tolerance =
Expand All @@ -415,6 +509,8 @@
const clipped = clippedTextIssue(element, time, tolerance);
if (clipped) issues.push(clipped);
issues.push(...textOverflowIssues(element, root, rootRect, time, tolerance));
const occluded = occludedTextIssue(element, time);
if (occluded) issues.push(occluded);
}

issues.push(...containerOverflowIssues(root, time, tolerance));
Expand Down
128 changes: 128 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,132 @@ describe("layout-audit.browser", () => {
});
});

describe("layout-audit.browser occlusion", () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = "";
delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint;
delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit;
});

it("flags text painted over by an opaque sibling overlay", () => {
const occluded = auditOcclusionScene({
overlayStyle: { backgroundColor: "rgb(10, 10, 10)" },
topmostId: "overlay",
}).find((issue) => issue.code === "text_occluded");
expect(occluded).toMatchObject({ selector: "#headline", containerSelector: "#overlay" });
});

it("reports occlusion only on the covered text, not the text itself when on top", () => {
// elementFromPoint returns the headline itself (it is on top), so nothing
// occludes it — the topmost-hit-is-self path must NOT flag.
const issues = auditOcclusionScene({
overlayStyle: { backgroundColor: "rgb(10, 10, 10)" },
topmostId: "headline",
});
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
});

it("ignores low-opacity overlays such as scrims and grain", () => {
const issues = auditOcclusionScene({
overlayStyle: { backgroundColor: "rgb(10, 10, 10)", opacity: "0.3" },
topmostId: "overlay",
});
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
});

it("respects the data-layout-allow-occlusion opt-out", () => {
const issues = auditOcclusionScene({
headlineAttrs: "data-layout-allow-occlusion",
overlayStyle: { backgroundColor: "rgb(10, 10, 10)" },
topmostId: "overlay",
});
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
});
});

function auditOcclusionScene(options: {
headlineAttrs?: string;
overlayStyle: Partial<Record<string, string>>;
topmostId: string;
}): ReturnType<typeof runAudit> {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="headline" ${options.headlineAttrs ?? ""}>Headline copy</div>
<div id="overlay"></div>
</div>
`;
installOcclusionGeometry({
styleOverrides: { overlay: options.overlayStyle },
headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }),
topmostId: options.topmostId,
});
installAuditScript();
return runAudit();
}

function installOcclusionGeometry(options: {
styleOverrides: Record<string, Partial<Record<string, string>>>;
headlineTextRect: DOMRect;
topmostId: string;
}): void {
const baseStyle: Record<string, string> = {
display: "block",
visibility: "visible",
opacity: "1",
overflow: "visible",
overflowX: "visible",
overflowY: "visible",
backgroundColor: "rgba(0, 0, 0, 0)",
backgroundImage: "none",
borderTopWidth: "0px",
borderRightWidth: "0px",
borderBottomWidth: "0px",
borderLeftWidth: "0px",
borderTopLeftRadius: "0px",
borderTopRightRadius: "0px",
borderBottomRightRadius: "0px",
borderBottomLeftRadius: "0px",
paddingTop: "0px",
paddingRight: "0px",
paddingBottom: "0px",
paddingLeft: "0px",
fontSize: "36px",
};

vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
const id = (element as Element).id;
return {
...baseStyle,
...(options.styleOverrides[id] ?? {}),
} as unknown as CSSStyleDeclaration;
});

for (const element of Array.from(document.querySelectorAll("*"))) {
vi.spyOn(element, "getBoundingClientRect").mockReturnValue(
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() {
return (selected as Element | null)?.id === "headline"
? ([options.headlineTextRect] as unknown as DOMRectList)
: ([] as unknown as DOMRectList);
},
detach() {},
} as unknown as Range;
});

(document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () =>
document.getElementById(options.topmostId);
}

function installAuditScript(): void {
window.eval(script);
}
Expand All @@ -109,6 +235,7 @@ function runAudit(): Array<{
selector: string;
containerSelector?: string;
overflow?: Record<string, number>;
message?: string;
}> {
const audit = (
window as unknown as {
Expand All @@ -117,6 +244,7 @@ function runAudit(): Array<{
selector: string;
containerSelector?: string;
overflow?: Record<string, number>;
message?: string;
}>;
}
).__hyperframesLayoutAudit;
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"
| "text_occluded";

export type LayoutIssueSeverity = "error" | "warning" | "info";

Expand Down
Loading