diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 1d57956fa..0d71599f7 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -546,7 +546,9 @@ } function alphaFromParts(parts, index) { - return parts.length > index ? parsePx(parts[index]) : 1; + if (parts.length <= index) return 1; + const raw = parts[index].trim(); + return raw.endsWith("%") ? parsePx(raw) / 100 : parsePx(raw); } // Alpha of a CSS colour; 1 when no alpha component is present. Handles both @@ -659,9 +661,71 @@ } function hasOpaqueBackground(style) { - if (style.backgroundImage && style.backgroundImage !== "none") return true; - if (isTransparentColor(style.backgroundColor)) return false; - return colorAlpha(style.backgroundColor) > 0.6; + let imageAlpha = 0; + if (style.backgroundImage && style.backgroundImage !== "none") { + if (style.backgroundImage.includes("url(")) return true; + // A gradient only occludes as much as its colours — a 4%-alpha grid/scrim must not count. + imageAlpha = gradientLayersAlpha(style.backgroundImage); + } + const colorValue = isTransparentColor(style.backgroundColor) + ? 0 + : colorAlpha(style.backgroundColor); + // Layers composite: a 0.5 gradient over a 0.5 background colour paints at ~0.75. + return 1 - (1 - imageAlpha) * (1 - colorValue) > 0.6; + } + + // background-image layers stack: two 0.5-alpha gradients paint at 1-(1-.5)^2 = .75. + function gradientLayersAlpha(backgroundImage) { + let combined = 0; + for (const layer of splitTopLevelCommas(backgroundImage)) { + combined = 1 - (1 - combined) * (1 - gradientMaxAlpha(layer)); + } + return combined; + } + + function splitTopLevelCommas(value) { + const parts = []; + let depth = 0; + let start = 0; + for (let i = 0; i < value.length; i += 1) { + const ch = value[i]; + if (ch === "(") depth += 1; + else if (ch === ")") depth -= 1; + else if (ch === "," && depth === 0) { + parts.push(value.slice(start, i)); + start = i + 1; + } + } + parts.push(value.slice(start)); + return parts; + } + + function gradientMaxAlpha(backgroundImage) { + // Any colour we cannot score (oklch/lab/named-colour fns/...) counts as opaque so real panels keep flagging. + const known = backgroundImage + .replace(/(?:repeating-)?(?:linear|radial|conic)-gradient\(/gi, "(") + .replace(/rgba?\([^)]*\)/gi, ""); + if (/[a-z][a-z-]+\(/i.test(known)) return 1; + const colors = backgroundImage.match(/rgba?\([^)]*\)|#[0-9a-fA-F]{3,8}\b|\btransparent\b/g); + if (!colors) return 1; + let max = 0; + for (const color of colors) { + if (color === "transparent") continue; + if (color.startsWith("#")) { + const hex = color.slice(1); + max = Math.max( + max, + hex.length === 4 + ? parseInt(hex[3] + hex[3], 16) / 255 + : hex.length === 8 + ? parseInt(hex.slice(6), 16) / 255 + : 1, + ); + } else { + max = Math.max(max, colorAlpha(color)); + } + } + return max; } const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]); @@ -722,13 +786,21 @@ // part of a transient crossfade overlap. // fallow-ignore-next-line complexity function occluderAt(element, x, y) { - if (typeof document.elementFromPoint !== "function") return null; - const hit = document.elementFromPoint(x, y); - if (!isForeignElement(element, hit)) return null; - if (sharedPreserve3d(element, hit)) return null; - if (!isOpaqueOccluder(hit)) return null; - if (isCrossSceneTransitionOverlap(element, hit)) return null; - return hit; + // Walk the paint-ordered stack: a transparent layer on top must not mask an opaque one below it. + const stack = + typeof document.elementsFromPoint === "function" + ? document.elementsFromPoint(x, y) + : typeof document.elementFromPoint === "function" + ? [document.elementFromPoint(x, y)].filter(Boolean) + : []; + for (const hit of stack) { + if (!isForeignElement(element, hit)) return null; + // Pair-specific exemptions excuse this hit only; keep walking for deeper occluders. + if (sharedPreserve3d(element, hit)) continue; + if (isCrossSceneTransitionOverlap(element, hit)) continue; + if (isOpaqueOccluder(hit)) return hit; + } + return null; } const OCCLUSION_PROBE_Y_FRACTIONS = [0.25, 0.5, 0.75]; @@ -768,6 +840,32 @@ return { occluder, coveredFraction: round(hits / OCCLUSION_GRID_POINTS) }; } + // pointer-events:none hides elements from elementFromPoint — both probed text AND occluders. + function restoreHitTesting(root) { + const restores = []; + for (const node of [root, ...root.querySelectorAll("*")]) { + if (getComputedStyle(node).pointerEvents !== "none") continue; + const previous = node.style.getPropertyValue("pointer-events"); + const priority = node.style.getPropertyPriority("pointer-events"); + node.style.setProperty("pointer-events", "auto", "important"); + restores.push(() => { + if (previous) node.style.setProperty("pointer-events", previous, priority); + else node.style.removeProperty("pointer-events"); + }); + } + return () => restores.forEach((restore) => restore()); + } + + // No text ink is on screen while every non-whitespace text node sits at ~0 opacity (entrance not started). + function hasVisibleTextInk(element) { + const nodes = [element, ...element.querySelectorAll("*")]; + for (const node of nodes) { + if (!directTextNodes(node).some((textNode) => textNode.textContent.trim())) continue; + if (opacityChain(node) >= 0.05) return true; + } + return false; + } + // Catches the blind spot the overflow checks miss: text that fits its box // perfectly but is covered by a later sibling/overlay. An atomic label // (short, no whitespace) flags at any coverage; ordinary prose only flags @@ -775,6 +873,7 @@ // cover on a paragraph is usually a styling artifact, not a reading defect. function occludedTextIssue(element, time) { if (hasAllowOcclusionFlag(element)) return null; + if (!hasVisibleTextInk(element)) return null; const textRect = textRectFor(element); if (!textRect) return null; const text = textContentFor(element); @@ -919,15 +1018,20 @@ ); const issues = []; - for (const element of elements) { - if (!hasOwnTextCandidate(element)) continue; - 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); - const invisible = invisibleTextIssue(element, time); - if (invisible) issues.push(invisible); + const restoreHits = restoreHitTesting(root); + try { + for (const element of elements) { + if (!hasOwnTextCandidate(element)) continue; + 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); + const invisible = invisibleTextIssue(element, time); + if (invisible) issues.push(invisible); + } + } finally { + restoreHits(); } 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 1a334a5b6..a3ecd4f6b 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -831,6 +831,7 @@ describe("layout-audit.browser occlusion", () => { vi.restoreAllMocks(); document.body.innerHTML = ""; delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint; + delete (document as unknown as { elementsFromPoint?: unknown }).elementsFromPoint; delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit; clearGeometryCollector(); }); @@ -904,6 +905,162 @@ describe("layout-audit.browser occlusion", () => { }); expect(issues.some((issue) => issue.code === "text_occluded")).toBe(true); }); + + it("does not flag visible text carrying pointer-events:none (probe restores hit-testing)", () => { + document.body.innerHTML = ` +
+
Headline copy
+
+
+ `; + installOcclusionGeometry({ + styleOverrides: { + headline: { pointerEvents: "none" }, + overlay: { backgroundColor: "rgb(10, 10, 10)" }, + }, + headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }), + topmostId: "overlay", + }); + // Simulate real hit-testing: with hit-testing restored (inline auto), the topmost hit IS the text. + (document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () => { + const headline = document.getElementById("headline"); + return headline?.style.getPropertyValue("pointer-events") === "auto" + ? headline + : document.getElementById("overlay"); + }; + installAuditScript(); + expect(runAudit().some((issue) => issue.code === "text_occluded")).toBe(false); + }); + + it("does not count a low-alpha gradient overlay (grid/scrim) as an opaque occluder", () => { + const issues = auditOcclusionScene({ + overlayStyle: { + backgroundImage: + "repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.04) 0px, transparent 1px)", + }, + topmostId: "overlay", + }); + expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false); + }); + + it("walks past a transparent layer sharing the text's 3D context to a deeper occluder", () => { + document.body.innerHTML = ` +
+
+
Headline copy
+
+
+
+
+ `; + installOcclusionGeometry({ + styleOverrides: { + stage: { transformStyle: "preserve-3d" }, + panel: { backgroundColor: "rgb(10, 10, 10)" }, + }, + headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }), + topmostId: "decor", + }); + (document as unknown as { elementsFromPoint: () => Element[] }).elementsFromPoint = () => + ["decor", "panel"].map((id) => document.getElementById(id) as Element); + installAuditScript(); + expect(runAudit().some((issue) => issue.code === "text_occluded")).toBe(true); + }); + + it("composites stacked translucent gradient layers (two 0.5-alpha layers occlude)", () => { + const occluded = auditOcclusionScene({ + overlayStyle: { + backgroundImage: + "linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5)), linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5))", + }, + topmostId: "overlay", + }).find((issue) => issue.code === "text_occluded"); + expect(occluded).toBeDefined(); + }); + + it("does not count a single 0.5-alpha gradient layer as an occluder", () => { + const issues = auditOcclusionScene({ + overlayStyle: { backgroundImage: "linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5))" }, + topmostId: "overlay", + }); + expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false); + }); + + it("probes text whose ink sits just above the 0.05 opacity floor", () => { + document.body.innerHTML = ` +
+
+ Headline copy +
+
+
+ `; + installOcclusionGeometry({ + styleOverrides: { + inner: { opacity: "0.06" }, + overlay: { backgroundColor: "rgb(10, 10, 10)" }, + }, + headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }), + topmostId: "overlay", + }); + installAuditScript(); + expect(runAudit().some((issue) => issue.code === "text_occluded")).toBe(true); + }); + + it("still counts an opaque gradient panel as an occluder", () => { + const occluded = auditOcclusionScene({ + overlayStyle: { backgroundImage: "linear-gradient(rgb(10, 10, 10), rgb(40, 40, 40))" }, + topmostId: "overlay", + }).find((issue) => issue.code === "text_occluded"); + expect(occluded).toBeDefined(); + }); + + it("still flags text buried under an occluder that itself has pointer-events:none", () => { + document.body.innerHTML = ` +
+
Headline copy
+
+
+ `; + installOcclusionGeometry({ + styleOverrides: { + overlay: { backgroundColor: "rgb(10, 10, 10)", pointerEvents: "none" }, + }, + headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }), + topmostId: "overlay", + }); + // Simulate hit-testing: the scrim is only hittable once the audit restores its pointer-events. + (document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () => { + const overlay = document.getElementById("overlay"); + return overlay?.style.getPropertyValue("pointer-events") === "auto" + ? overlay + : document.getElementById("headline"); + }; + installAuditScript(); + const occluded = runAudit().find((issue) => issue.code === "text_occluded"); + expect(occluded).toMatchObject({ selector: "#headline", containerSelector: "#overlay" }); + }); + + it("does not probe text whose every text node is still at opacity 0 (whitespace-indented markup)", () => { + document.body.innerHTML = ` +
+
+ Headline copy +
+
+
+ `; + installOcclusionGeometry({ + styleOverrides: { + inner: { opacity: "0" }, + overlay: { backgroundColor: "rgb(10, 10, 10)" }, + }, + headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }), + topmostId: "overlay", + }); + installAuditScript(); + expect(runAudit().some((issue) => issue.code === "text_occluded")).toBe(false); + }); }); // Mirrors OCCLUSION_PROBE_Y_FRACTIONS / OCCLUSION_PROBE_X_FRACTIONS in