fix(cli): audit text beneath transparent composition boxes (#5202)

This commit is contained in:
Abdullah M. Nazif
2026-10-08 20:35:23 +00:00
committed by GitHub
parent f802aedb5b
commit b32ce10b77
2 changed files with 269 additions and 4 deletions
@@ -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 = [];
@@ -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 = `
<div id="root" data-composition-id="main" style="opacity: 1">
<div id="headline" data-layout-allow-occlusion
style="font-size: 32px; color: rgb(51, 51, 51); opacity: 1">Visible copy</div>
<div id="overlay" data-composition-id="overlay" style="background-color: transparent; background-image: none"></div>
</div>
`;
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 = '<section id="overlay" data-composition-id="overlay"></section>';
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([]);
});
});