Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 60 additions & 4 deletions packages/cli/src/commands/contrast-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,16 +104,72 @@ 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.
for (var ci = 0; ci < CLIP_PROBE_COLS.length; ci++) {
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;
Expand All @@ -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 = [];
Expand Down
209 changes: 209 additions & 0 deletions packages/cli/src/commands/contrast-occlusion.browser.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading