diff --git a/packages/cli/src/commands/contrast-audit.browser.js b/packages/cli/src/commands/contrast-audit.browser.js
index 5318e04319..79f7016e3b 100644
--- a/packages/cli/src/commands/contrast-audit.browser.js
+++ b/packages/cli/src/commands/contrast-audit.browser.js
@@ -104,7 +104,57 @@ window.__contrastAuditPrepare = function () {
var CLIP_PROBE_COLS = [0.05, 0.25, 0.5, 0.75, 0.95];
var CLIP_PROBE_ROWS = [0.25, 0.5, 0.75];
- function paintsAnyProbePoint(el, rect) {
+ var compositionBoxPaint = new WeakMap();
+ var COMPOSITION_BOX_TAGS = new Set(["DIV", "SECTION"]);
+
+ function hasContainerPaint(style) {
+ var background = parseColor(style.backgroundColor);
+ if (
+ style.backgroundColor &&
+ style.backgroundColor !== "transparent" &&
+ (!background || background[3] > 0)
+ )
+ return true;
+ if (
+ ["backgroundImage", "boxShadow", "filter", "backdropFilter"].some(function (property) {
+ return style[property] && style[property] !== "none";
+ })
+ )
+ return true;
+ if (parseFloat(style.outlineWidth) > 0 && style.outlineStyle !== "none") return true;
+ return ["Top", "Right", "Bottom", "Left"].some(function (side) {
+ return (
+ parseFloat(style["border" + side + "Width"]) > 0 &&
+ style["border" + side + "Style"] !== "none"
+ );
+ });
+ }
+
+ function isUnpaintedCompositionBox(el) {
+ if (!el || !el.matches("[data-composition-id], [data-hf-inner-root]")) return false;
+ if (!el.closest("[data-composition-id]")) return false;
+ if (
+ el.namespaceURI !== "http://www.w3.org/1999/xhtml" ||
+ !COMPOSITION_BOX_TAGS.has(el.tagName) ||
+ el.shadowRoot
+ )
+ return false;
+ if (compositionBoxPaint.has(el)) return !compositionBoxPaint.get(el);
+ var painted =
+ Array.from(el.childNodes).some(function (child) {
+ return child.nodeType === 3 && (child.textContent || "").trim().length > 0;
+ }) || hasContainerPaint(getComputedStyle(el));
+ if (!painted) {
+ painted = ["::before", "::after"].some(function (pseudo) {
+ var content = getComputedStyle(el, pseudo).content;
+ return content && content !== "none" && content !== "normal";
+ });
+ }
+ compositionBoxPaint.set(el, !!painted);
+ return !painted;
+ }
+
+ function paintsAnyProbePoint(el, rect, skipCompositionBoxes) {
// Keep probe resolution aligned with layout-audit.browser.js. Edge strips
// narrower than the nearest probe point are treated as clipped away to
// avoid noisy typewriter pre-reveal contrast reports.
@@ -112,8 +162,14 @@ window.__contrastAuditPrepare = function () {
for (var ri = 0; ri < CLIP_PROBE_ROWS.length; ri++) {
var x = rect.left + rect.width * CLIP_PROBE_COLS[ci];
var y = rect.top + rect.height * CLIP_PROBE_ROWS[ri];
- var hit = document.elementFromPoint(x, y);
- if (hit === el || el.contains(hit)) return true;
+ var hits =
+ skipCompositionBoxes && typeof document.elementsFromPoint === "function"
+ ? document.elementsFromPoint(x, y)
+ : [document.elementFromPoint(x, y)];
+ for (var hit of hits) {
+ if (hit === el || el.contains(hit)) return true;
+ if (!skipCompositionBoxes || !isUnpaintedCompositionBox(hit)) break;
+ }
}
}
return false;
@@ -135,7 +191,7 @@ window.__contrastAuditPrepare = function () {
function isIntentionallyOccluded(el, rect) {
if (typeof document.elementFromPoint !== "function") return false;
if (!el.closest || !el.closest("[data-layout-allow-occlusion]")) return false;
- return !paintsAnyProbePoint(el, rect);
+ return !paintsAnyProbePoint(el, rect, true);
}
var out = [];
diff --git a/packages/cli/src/commands/contrast-occlusion.browser.test.ts b/packages/cli/src/commands/contrast-occlusion.browser.test.ts
new file mode 100644
index 0000000000..9bb2ec0141
--- /dev/null
+++ b/packages/cli/src/commands/contrast-occlusion.browser.test.ts
@@ -0,0 +1,209 @@
+// @vitest-environment happy-dom
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+
+const script = readFileSync(
+ join(dirname(fileURLToPath(import.meta.url)), "contrast-audit.browser.js"),
+ "utf8",
+);
+const computedStyle = window.getComputedStyle.bind(window);
+
+function element(id: string): HTMLElement {
+ const found = document.getElementById(id);
+ if (!found) throw new Error("Missing fixture element: " + id);
+ return found;
+}
+
+function installStack(ids: string[]): void {
+ const stack = () => ids.map(element);
+ Object.defineProperty(document, "elementsFromPoint", {
+ configurable: true,
+ value: vi.fn(stack),
+ });
+ Object.defineProperty(document, "elementFromPoint", {
+ configurable: true,
+ value: vi.fn(() => stack()[0] ?? null),
+ });
+}
+
+function prepare(): unknown {
+ window.eval(script);
+ return window.eval("window.__contrastAuditPrepare()");
+}
+
+beforeEach(() => {
+ document.body.innerHTML = `
+
+ `;
+ vi.spyOn(element("headline"), "getBoundingClientRect").mockReturnValue({
+ x: 100,
+ y: 100,
+ left: 100,
+ top: 100,
+ right: 500,
+ bottom: 140,
+ width: 400,
+ height: 40,
+ toJSON() {
+ return {};
+ },
+ });
+ const emptyPseudo = document.createElement("span").style;
+ emptyPseudo.content = "none";
+ // Happy DOM does not calculate pseudo-element styles.
+ vi.spyOn(window, "getComputedStyle").mockImplementation((el, pseudo) =>
+ pseudo ? emptyPseudo : computedStyle(el),
+ );
+ installStack(["overlay", "headline", "root"]);
+});
+
+afterEach(() => {
+ window.eval("window.__contrastAuditRestoreIfPending?.()");
+ vi.restoreAllMocks();
+ document.body.innerHTML = "";
+ Reflect.deleteProperty(document, "elementFromPoint");
+ Reflect.deleteProperty(document, "elementsFromPoint");
+ for (const name of [
+ "__contrastAuditPrepare",
+ "__contrastAuditFinish",
+ "__contrastAuditRestoreIfPending",
+ "__contrastAuditRestores",
+ ])
+ Reflect.deleteProperty(window, name);
+});
+
+describe("contrast audit beneath composition boxes", () => {
+ it("audits visible text beneath a transparent composition host", () => {
+ expect(prepare()).toContainEqual(expect.objectContaining({ selector: "#headline" }));
+ });
+
+ it("walks through nested transparent composition roots", () => {
+ const nested = document.createElement("div");
+ nested.id = "nested";
+ nested.dataset.compositionId = "nested";
+ element("overlay").appendChild(nested);
+ installStack(["nested", "overlay", "headline", "root"]);
+ expect(prepare()).toContainEqual(expect.objectContaining({ selector: "#headline" }));
+ });
+
+ it("walks through the compiler's flattened inner root and its host", () => {
+ const inner = document.createElement("div");
+ inner.id = "inner";
+ inner.dataset.hfInnerRoot = "true";
+ element("overlay").appendChild(inner);
+ installStack(["inner", "overlay", "headline", "root"]);
+ expect(prepare()).toContainEqual(expect.objectContaining({ selector: "#headline" }));
+ });
+
+ it("retains an opaque flattened inner root", () => {
+ const inner = document.createElement("div");
+ inner.id = "inner";
+ inner.dataset.hfInnerRoot = "true";
+ inner.style.background = "white";
+ element("overlay").appendChild(inner);
+ installStack(["inner", "overlay", "headline", "root"]);
+ expect(prepare()).toEqual([]);
+ });
+
+ it("does not exempt a marked inner root outside a composition", () => {
+ element("overlay").removeAttribute("data-composition-id");
+ element("overlay").dataset.hfInnerRoot = "true";
+ document.body.appendChild(element("overlay"));
+ expect(prepare()).toEqual([]);
+ });
+
+ it("does not walk past an opaque layer beneath a transparent host", () => {
+ const cover = document.createElement("div");
+ cover.id = "cover";
+ cover.style.background = "white";
+ element("overlay").before(cover);
+ installStack(["overlay", "cover", "headline", "root"]);
+ expect(prepare()).toEqual([]);
+ });
+
+ it.each([
+ ["background color", "background-color", "rgb(255, 255, 255)"],
+ ["background image", "background-image", "linear-gradient(white, white)"],
+ ["box shadow", "box-shadow", "0 0 0 1000px white"],
+ ["filter", "filter", "drop-shadow(0 0 100px white)"],
+ ["backdrop filter", "backdrop-filter", "blur(20px)"],
+ ["border", "border", "100px solid white"],
+ ["outline", "outline", "100px solid white"],
+ ])("retains the occlusion behavior for a host with %s", (_name, property, value) => {
+ element("overlay").style.setProperty(property, value);
+ expect(prepare()).toEqual([]);
+ });
+
+ it.each(["::before", "::after"])("retains a host with generated %s content", (pseudo) => {
+ const pseudoStyle = document.createElement("span").style;
+ pseudoStyle.content = '""';
+ pseudoStyle.backgroundColor = "white";
+ vi.spyOn(window, "getComputedStyle").mockImplementation((el, target) =>
+ target
+ ? el.id === "overlay" && target === pseudo
+ ? pseudoStyle
+ : document.createElement("span").style
+ : computedStyle(el),
+ );
+ expect(prepare()).toEqual([]);
+ });
+
+ it("retains a composition host that paints its own direct text", () => {
+ element("overlay").append("Foreground copy");
+ expect(prepare()).toEqual([]);
+ });
+
+ it("does not exempt unrelated transparent elements", () => {
+ element("overlay").removeAttribute("data-composition-id");
+ expect(prepare()).toEqual([]);
+ });
+
+ it("audits text beneath a transparent section root", () => {
+ element("overlay").outerHTML = '';
+ expect(prepare()).toContainEqual(expect.objectContaining({ selector: "#headline" }));
+ });
+
+ it("does not exempt a composition host with a shadow root", () => {
+ element("overlay").attachShadow({ mode: "open" }).innerHTML = "Foreground copy";
+ expect(prepare()).toEqual([]);
+ });
+
+ it.each(["canvas", "video", "img", "iframe", "object", "embed", "svg", "input", "button"])(
+ "does not exempt a %s with a composition attribute",
+ (tag) => {
+ element("overlay").outerHTML = `<${tag} id="overlay" data-composition-id="overlay">${tag}>`;
+ expect(prepare()).toEqual([]);
+ },
+ );
+
+ it("keeps the existing single-hit behavior when the hit stack is unavailable", () => {
+ Reflect.deleteProperty(document, "elementsFromPoint");
+ expect(prepare()).toEqual([]);
+ });
+
+ it("audits a partly covered block when any probe reaches the text", () => {
+ const top = (x: number) => element(x > 300 ? "headline" : "overlay");
+ Object.defineProperty(document, "elementsFromPoint", {
+ configurable: true,
+ value: vi.fn((x: number) => [top(x)]),
+ });
+ Object.defineProperty(document, "elementFromPoint", {
+ configurable: true,
+ value: vi.fn(top),
+ });
+ element("overlay").style.backgroundColor = "rgb(255, 255, 255)";
+ expect(prepare()).toContainEqual(expect.objectContaining({ selector: "#headline" }));
+ });
+
+ it("does not turn clipped-away text into a contrast candidate", () => {
+ element("headline").style.clipPath = "inset(0 100% 0 0)";
+ installStack(["overlay", "root"]);
+ expect(prepare()).toEqual([]);
+ });
+});