Skip to content

Commit 6f0110f

Browse files
feat(cli): flag text occluded by opaque elements in inspect
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 <miguel07alm@protonmail.com>
1 parent 211e0ad commit 6f0110f

5 files changed

Lines changed: 233 additions & 3 deletions

File tree

.fallowrc.jsonc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
// Built as standalone IIFE for the browser-side sandbox runtime;
2121
// referenced by file path (not import) in build-hyperframes-runtime-artifact.ts.
2222
"packages/core/src/runtime/entry.ts",
23+
// In-page audit scripts read as raw strings and injected via
24+
// page.addScriptTag (see layout.ts / validate.ts) — referenced by file
25+
// path, never imported, so they have no import-graph referrer.
26+
"packages/cli/src/commands/layout-audit.browser.js",
27+
"packages/cli/src/commands/contrast-audit.browser.js",
2328
// Worker entry points loaded dynamically by their *Pool.ts companions.
2429
"packages/producer/src/services/pngDecodeBlitWorker.ts",
2530
"packages/producer/src/services/shaderTransitionWorker.ts",

docs/packages/cli.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -532,7 +532,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
532532
◇ 1 error(s), 0 warning(s), 0 info(s)
533533
```
534534

535-
`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.
535+
`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.
536536

537537
| Flag | Description |
538538
|------|-------------|
@@ -545,7 +545,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
545545
| `--max-issues` | Maximum findings to print or return after static collapse (default: 80) |
546546
| `--strict` | Exit non-zero on warnings as well as errors |
547547

548-
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.
548+
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).
549549

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

packages/cli/src/commands/layout-audit.browser.js

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,100 @@
398398
return issues;
399399
}
400400

401+
function isTransparentColor(color) {
402+
return (
403+
!color || color === "transparent" || color === "rgba(0, 0, 0, 0)" || color.endsWith(", 0)")
404+
);
405+
}
406+
407+
function alphaFromParts(parts, index) {
408+
return parts.length > index ? parsePx(parts[index]) : 1;
409+
}
410+
411+
function colorAlpha(color) {
412+
const match = color.match(/rgba?\(([^)]+)\)/);
413+
if (!match) return 1;
414+
// Legacy `rgba(r, g, b, a)` keeps alpha as the 4th comma part; modern
415+
// `rgb(r g b / a)` puts it after a slash. No alpha component → opaque.
416+
const body = match[1];
417+
return body.includes(",")
418+
? alphaFromParts(body.split(","), 3)
419+
: alphaFromParts(body.split("/"), 1);
420+
}
421+
422+
function hasOpaqueBackground(style) {
423+
if (style.backgroundImage && style.backgroundImage !== "none") return true;
424+
if (isTransparentColor(style.backgroundColor)) return false;
425+
return colorAlpha(style.backgroundColor) > 0.6;
426+
}
427+
428+
const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]);
429+
430+
// An element hides text beneath it when it paints opaque pixels at near-full
431+
// opacity: raster content (img/video/canvas), a background image, or a solid
432+
// background colour. Low-opacity overlays (grain, scrims) do not occlude.
433+
function isOpaqueOccluder(element) {
434+
if (opacityChain(element) < 0.6) return false;
435+
if (IGNORE_TAGS.has(element.tagName)) return false;
436+
if (RASTER_TAGS.has(element.tagName)) return true;
437+
return hasOpaqueBackground(getComputedStyle(element));
438+
}
439+
440+
function hasAllowOcclusionFlag(element) {
441+
return !!element.closest("[data-layout-allow-occlusion]");
442+
}
443+
444+
// A foreign element is one painted independently of the text — not the text
445+
// itself, its own subtree, or an ancestor it shares a background with.
446+
function isForeignElement(element, hit) {
447+
return !!hit && hit !== element && !element.contains(hit) && !hit.contains(element);
448+
}
449+
450+
// The opaque element painted over (x, y), or null when the topmost element
451+
// there is related to the text or non-opaque.
452+
function occluderAt(element, x, y) {
453+
if (typeof document.elementFromPoint !== "function") return null;
454+
const hit = document.elementFromPoint(x, y);
455+
if (!isForeignElement(element, hit)) return null;
456+
return isOpaqueOccluder(hit) ? hit : null;
457+
}
458+
459+
// Sweep a grid across the text box (three rows, not just the mid-line, so
460+
// overlays covering only part of a multi-line block are caught) and return
461+
// the first opaque element painted over any sample point.
462+
function firstOccluder(element, textRect) {
463+
for (const yFraction of [0.25, 0.5, 0.75]) {
464+
const y = textRect.top + textRect.height * yFraction;
465+
for (const xFraction of [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97]) {
466+
const occluder = occluderAt(element, textRect.left + textRect.width * xFraction, y);
467+
if (occluder) return occluder;
468+
}
469+
}
470+
return null;
471+
}
472+
473+
// Catches the blind spot the overflow checks miss: text that fits its box
474+
// perfectly but is covered by a later sibling/overlay.
475+
function occludedTextIssue(element, time) {
476+
if (hasAllowOcclusionFlag(element)) return null;
477+
const textRect = textRectFor(element);
478+
if (!textRect) return null;
479+
const occluder = firstOccluder(element, textRect);
480+
if (!occluder) return null;
481+
return {
482+
code: "text_occluded",
483+
severity: "error",
484+
time,
485+
selector: selectorFor(element),
486+
containerSelector: selectorFor(occluder),
487+
text: textContentFor(element),
488+
message: "Text is hidden beneath an opaque element.",
489+
rect: textRect,
490+
fixHint:
491+
"Give the text its own zone, raise its stacking order above the covering element, or mark intentional layering with data-layout-allow-occlusion.",
492+
};
493+
}
494+
401495
window.__hyperframesLayoutAudit = function auditLayout(options) {
402496
const time = options && typeof options.time === "number" ? options.time : 0;
403497
const tolerance =
@@ -415,6 +509,8 @@
415509
const clipped = clippedTextIssue(element, time, tolerance);
416510
if (clipped) issues.push(clipped);
417511
issues.push(...textOverflowIssues(element, root, rootRect, time, tolerance));
512+
const occluded = occludedTextIssue(element, time);
513+
if (occluded) issues.push(occluded);
418514
}
419515

420516
issues.push(...containerOverflowIssues(root, time, tolerance));

packages/cli/src/commands/layout-audit.browser.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,132 @@ describe("layout-audit.browser", () => {
100100
});
101101
});
102102

103+
describe("layout-audit.browser occlusion", () => {
104+
afterEach(() => {
105+
vi.restoreAllMocks();
106+
document.body.innerHTML = "";
107+
delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint;
108+
delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit;
109+
});
110+
111+
it("flags text painted over by an opaque sibling overlay", () => {
112+
const occluded = auditOcclusionScene({
113+
overlayStyle: { backgroundColor: "rgb(10, 10, 10)" },
114+
topmostId: "overlay",
115+
}).find((issue) => issue.code === "text_occluded");
116+
expect(occluded).toMatchObject({ selector: "#headline", containerSelector: "#overlay" });
117+
});
118+
119+
it("reports occlusion only on the covered text, not the text itself when on top", () => {
120+
// elementFromPoint returns the headline itself (it is on top), so nothing
121+
// occludes it — the topmost-hit-is-self path must NOT flag.
122+
const issues = auditOcclusionScene({
123+
overlayStyle: { backgroundColor: "rgb(10, 10, 10)" },
124+
topmostId: "headline",
125+
});
126+
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
127+
});
128+
129+
it("ignores low-opacity overlays such as scrims and grain", () => {
130+
const issues = auditOcclusionScene({
131+
overlayStyle: { backgroundColor: "rgb(10, 10, 10)", opacity: "0.3" },
132+
topmostId: "overlay",
133+
});
134+
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
135+
});
136+
137+
it("respects the data-layout-allow-occlusion opt-out", () => {
138+
const issues = auditOcclusionScene({
139+
headlineAttrs: "data-layout-allow-occlusion",
140+
overlayStyle: { backgroundColor: "rgb(10, 10, 10)" },
141+
topmostId: "overlay",
142+
});
143+
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
144+
});
145+
});
146+
147+
function auditOcclusionScene(options: {
148+
headlineAttrs?: string;
149+
overlayStyle: Partial<Record<string, string>>;
150+
topmostId: string;
151+
}): ReturnType<typeof runAudit> {
152+
document.body.innerHTML = `
153+
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
154+
<div id="headline" ${options.headlineAttrs ?? ""}>Headline copy</div>
155+
<div id="overlay"></div>
156+
</div>
157+
`;
158+
installOcclusionGeometry({
159+
styleOverrides: { overlay: options.overlayStyle },
160+
headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }),
161+
topmostId: options.topmostId,
162+
});
163+
installAuditScript();
164+
return runAudit();
165+
}
166+
167+
function installOcclusionGeometry(options: {
168+
styleOverrides: Record<string, Partial<Record<string, string>>>;
169+
headlineTextRect: DOMRect;
170+
topmostId: string;
171+
}): void {
172+
const baseStyle: Record<string, string> = {
173+
display: "block",
174+
visibility: "visible",
175+
opacity: "1",
176+
overflow: "visible",
177+
overflowX: "visible",
178+
overflowY: "visible",
179+
backgroundColor: "rgba(0, 0, 0, 0)",
180+
backgroundImage: "none",
181+
borderTopWidth: "0px",
182+
borderRightWidth: "0px",
183+
borderBottomWidth: "0px",
184+
borderLeftWidth: "0px",
185+
borderTopLeftRadius: "0px",
186+
borderTopRightRadius: "0px",
187+
borderBottomRightRadius: "0px",
188+
borderBottomLeftRadius: "0px",
189+
paddingTop: "0px",
190+
paddingRight: "0px",
191+
paddingBottom: "0px",
192+
paddingLeft: "0px",
193+
fontSize: "36px",
194+
};
195+
196+
vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
197+
const id = (element as Element).id;
198+
return {
199+
...baseStyle,
200+
...(options.styleOverrides[id] ?? {}),
201+
} as unknown as CSSStyleDeclaration;
202+
});
203+
204+
for (const element of Array.from(document.querySelectorAll("*"))) {
205+
vi.spyOn(element, "getBoundingClientRect").mockReturnValue(
206+
rect({ left: 0, top: 0, width: 1920, height: 1080 }),
207+
);
208+
}
209+
210+
vi.spyOn(document, "createRange").mockImplementation(() => {
211+
let selected: Node | null = null;
212+
return {
213+
selectNodeContents(node: Node) {
214+
selected = node;
215+
},
216+
getClientRects() {
217+
return (selected as Element | null)?.id === "headline"
218+
? ([options.headlineTextRect] as unknown as DOMRectList)
219+
: ([] as unknown as DOMRectList);
220+
},
221+
detach() {},
222+
} as unknown as Range;
223+
});
224+
225+
(document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () =>
226+
document.getElementById(options.topmostId);
227+
}
228+
103229
function installAuditScript(): void {
104230
window.eval(script);
105231
}
@@ -109,6 +235,7 @@ function runAudit(): Array<{
109235
selector: string;
110236
containerSelector?: string;
111237
overflow?: Record<string, number>;
238+
message?: string;
112239
}> {
113240
const audit = (
114241
window as unknown as {
@@ -117,6 +244,7 @@ function runAudit(): Array<{
117244
selector: string;
118245
containerSelector?: string;
119246
overflow?: Record<string, number>;
247+
message?: string;
120248
}>;
121249
}
122250
).__hyperframesLayoutAudit;

packages/cli/src/utils/layoutAudit.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ export type LayoutIssueCode =
1313
| "text_box_overflow"
1414
| "clipped_text"
1515
| "canvas_overflow"
16-
| "container_overflow";
16+
| "container_overflow"
17+
| "text_occluded";
1718

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

0 commit comments

Comments
 (0)