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 = ` +
+
Visible copy
+
+
+ `; + 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">`; + 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([]); + }); +});