From 6f0110f2e1dd1578408e5ef3e0de4e2a408dd289 Mon Sep 17 00:00:00 2001 From: LeopoldTR Date: Sun, 14 Jun 2026 11:18:38 +0200 Subject: [PATCH] feat(cli): flag text occluded by opaque elements in inspect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layout audit only reported boxes that overflow their container; text that fits perfectly but is painted over by a later sibling or overlay was never caught. Add a text_occluded check that sweeps a grid across each text box (three rows x nine columns) and, via elementFromPoint, flags text whose topmost element is an unrelated opaque element (raster content, background image, or a solid background at near-full opacity). Low-opacity overlays such as scrims and grain are exempt. Opt out of intentional layering with data-layout-allow-occlusion. The two *.browser.js audit scripts are added to the fallow entry list: they are injected by path via page.addScriptTag, so they have no import-graph referrer. Co-authored-by: Miguel Ángel --- .fallowrc.jsonc | 5 + docs/packages/cli.mdx | 4 +- .../cli/src/commands/layout-audit.browser.js | 96 +++++++++++++ .../src/commands/layout-audit.browser.test.ts | 128 ++++++++++++++++++ packages/cli/src/utils/layoutAudit.ts | 3 +- 5 files changed, 233 insertions(+), 3 deletions(-) diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 878728bcb..2cf25c966 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -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", diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 551693b5b..92514cbe7 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -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 | |------|-------------| @@ -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: diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index aebcfdfac..02b91c39c 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -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 = @@ -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)); diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 981c052df..d40df7eca 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -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>; + topmostId: string; +}): ReturnType { + document.body.innerHTML = ` +
+
Headline copy
+
+
+ `; + 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>>; + headlineTextRect: DOMRect; + topmostId: string; +}): void { + const baseStyle: Record = { + 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); } @@ -109,6 +235,7 @@ function runAudit(): Array<{ selector: string; containerSelector?: string; overflow?: Record; + message?: string; }> { const audit = ( window as unknown as { @@ -117,6 +244,7 @@ function runAudit(): Array<{ selector: string; containerSelector?: string; overflow?: Record; + message?: string; }>; } ).__hyperframesLayoutAudit; diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index f02e8211b..7096886fb 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" + | "text_occluded"; export type LayoutIssueSeverity = "error" | "warning" | "info";