feat(cli): flag text occluded by opaque elements in inspect (#1435)

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>
This commit is contained in:
Leopold T
2026-06-15 11:05:12 -04:00
committed by GitHub
co-authored by Miguel Ángel
parent 3f3293da86
commit 1e54827957
5 changed files with 218 additions and 3 deletions
@@ -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));
@@ -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<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);
}
@@ -203,6 +329,7 @@ function runAudit(): Array<{
selector: string;
containerSelector?: string;
overflow?: Record<string, number>;
message?: string;
}> {
const audit = (
window as unknown as {
@@ -211,6 +338,7 @@ function runAudit(): Array<{
selector: string;
containerSelector?: string;
overflow?: Record<string, number>;
message?: string;
}>;
}
).__hyperframesLayoutAudit;
+2 -1
View File
@@ -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";