From 551bc1c57f0feea0a7f80e19df85c7d700e6f3e5 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 | 81 +++++++++++ .../src/commands/layout-audit.browser.test.ts | 128 ++++++++++++++++++ packages/cli/src/utils/layoutAudit.ts | 3 +- 5 files changed, 218 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 b66be00c2..748ea3f16 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -559,7 +559,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, 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. + `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`) and 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 | |------|-------------| @@ -572,7 +572,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-overlap` on a text element that is intentionally stacked over another (for example a lower-third caption above a heading). + 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). 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 3ce424d53..99f8f8aba 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -402,6 +402,12 @@ return !!element.closest("[data-layout-allow-overlap]"); } + 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; } @@ -483,6 +489,79 @@ return issues; } + 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 = @@ -500,6 +579,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 00bd35614..ab592ae2d 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -194,6 +194,132 @@ function auditOverlapScene(options: { return runAudit(); } +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); } @@ -203,6 +329,7 @@ function runAudit(): Array<{ selector: string; containerSelector?: string; overflow?: Record; + message?: string; }> { const audit = ( window as unknown as { @@ -211,6 +338,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 19d89af5c..fcf845943 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -14,7 +14,8 @@ export type LayoutIssueCode = | "clipped_text" | "canvas_overflow" | "container_overflow" - | "content_overlap"; + | "content_overlap" + | "text_occluded"; export type LayoutIssueSeverity = "error" | "warning" | "info";