From d88e0ea443afa8276a11d38f763119bb3b239920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 13 Jul 2026 09:25:51 +0000 Subject: [PATCH] fix(cli): audit pointer-transparent text by paint order --- .../cli/src/commands/layout-audit.browser.js | 25 ++++++++++++++- .../src/commands/layout-audit.browser.test.ts | 31 +++++++++++++++++-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 1d57956fa1..591bbb2b35 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -717,13 +717,36 @@ return !!ctx && ctx === preserve3dContext(b); } + // elementFromPoint intentionally skips pointer-events:none content, even + // though that content still paints. Caption rails commonly disable pointer + // events while sitting above a background video, so probe the text with hit + // testing temporarily enabled and restore its authored inline style. + function paintedElementFromPoint(element, x, y) { + if (getComputedStyle(element).pointerEvents !== "none") { + return document.elementFromPoint(x, y); + } + const property = "pointer-events"; + const previousValue = element.style.getPropertyValue(property); + const previousPriority = element.style.getPropertyPriority(property); + element.style.setProperty(property, "auto", "important"); + try { + return document.elementFromPoint(x, y); + } finally { + if (previousValue) { + element.style.setProperty(property, previousValue, previousPriority); + } else { + element.style.removeProperty(property); + } + } + } + // The opaque element painted over (x, y), or null when the topmost element // there is related to the text, non-opaque, sharing a 3D context with it, or // 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); + const hit = paintedElementFromPoint(element, x, y); if (!isForeignElement(element, hit)) return null; if (sharedPreserve3d(element, hit)) return null; if (!isOpaqueOccluder(hit)) return null; diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 1a334a5b68..e4794f009c 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -853,6 +853,19 @@ describe("layout-audit.browser occlusion", () => { expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false); }); + it("ignores a video beneath pointer-transparent text that is painted on top", () => { + const issues = auditOcclusionScene({ + headlineAttrs: 'style="pointer-events: none"', + overlayTag: "video", + overlayStyle: {}, + topmostId: "overlay", + topmostWhenHeadlineHitTestableId: "headline", + }); + + expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false); + expect(document.getElementById("headline")?.style.pointerEvents).toBe("none"); + }); + it("ignores low-opacity overlays such as scrims and grain", () => { const issues = auditOcclusionScene({ overlayStyle: { backgroundColor: "rgb(10, 10, 10)", opacity: "0.3" }, @@ -957,19 +970,24 @@ function auditCoverageScene(options: { function auditOcclusionScene(options: { headlineAttrs?: string; + overlayTag?: "div" | "video"; overlayStyle: Partial>; topmostId: string; + topmostWhenHeadlineHitTestableId?: string; }): ReturnType { + const overlay = + options.overlayTag === "video" ? '' : '
'; document.body.innerHTML = `
Headline copy
-
+ ${overlay}
`; installOcclusionGeometry({ styleOverrides: { overlay: options.overlayStyle }, headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }), topmostId: options.topmostId, + topmostWhenHeadlineHitTestableId: options.topmostWhenHeadlineHitTestableId, }); installAuditScript(); return runAudit(); @@ -979,6 +997,7 @@ function installOcclusionGeometry(options: { styleOverrides: Record>>; headlineTextRect: DOMRect; topmostId: string; + topmostWhenHeadlineHitTestableId?: string; }): void { const baseStyle: Record = { display: "block", @@ -1008,6 +1027,7 @@ function installOcclusionGeometry(options: { const id = (element as Element).id; return { ...baseStyle, + pointerEvents: (element as HTMLElement).style.pointerEvents || "auto", ...(options.styleOverrides[id] ?? {}), } as unknown as CSSStyleDeclaration; }); @@ -1033,8 +1053,13 @@ function installOcclusionGeometry(options: { } as unknown as Range; }); - (document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () => - document.getElementById(options.topmostId); + (document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () => { + const headline = document.getElementById("headline"); + if (headline?.style.pointerEvents === "auto" && options.topmostWhenHeadlineHitTestableId) { + return document.getElementById(options.topmostWhenHeadlineHitTestableId); + } + return document.getElementById(options.topmostId); + }; } function installAuditScript(): void {