diff --git a/packages/cli/src/commands/contrast-audit.browser.js b/packages/cli/src/commands/contrast-audit.browser.js index ab52ba9f8..3c48e2ef5 100644 --- a/packages/cli/src/commands/contrast-audit.browser.js +++ b/packages/cli/src/commands/contrast-audit.browser.js @@ -154,35 +154,61 @@ window.__contrastAudit = async function (imgBase64, time) { var fg = parseColor(cs.color); if (fg[3] <= 0.01) continue; - // Sample 4px ring outside bbox for background color - var rr = [], - gg = [], - bb = []; - var x0 = Math.max(0, Math.floor(rect.x) - 4); - var x1 = Math.min(w - 1, Math.ceil(rect.x + rect.width) + 4); - var y0 = Math.max(0, Math.floor(rect.y) - 4); - var y1 = Math.min(h - 1, Math.ceil(rect.y + rect.height) + 4); - var sample = function (sx, sy) { - if (sx < 0 || sx >= w || sy < 0 || sy >= h) return; - var idx = (sy * w + sx) * 4; - rr.push(px[idx]); - gg.push(px[idx + 1]); - bb.push(px[idx + 2]); - }; - for (var x = x0; x <= x1; x++) { - sample(x, y0); - sample(x, y1); - } - for (var y = y0; y <= y1; y++) { - sample(x0, y); - sample(x1, y); + // Prefer an opaque own/ancestor background-color over the pixel ring. A + // caption/CTA that paints its OWN solid background (a pill, a button, a + // card) composites its text over THAT color, not over whatever surrounds + // the box. Sampling the ring there measured the text against the scene + // behind the element (often a dark photo) and reported false ~1:1 warnings + // for perfectly readable CTAs. Walk up until a fully-opaque background-color + // is found; stop and defer to the ring on the first background-image (text + // over real pixels). Keep this in sync with commands/contrast-bg.ts. + var ownBg = null; + for (var bn = el; bn && bn !== document.body; bn = bn.parentElement) { + var bcs = getComputedStyle(bn); + if (bcs.backgroundImage && bcs.backgroundImage !== "none") break; + var bc = parseColor(bcs.backgroundColor); + if (bc[3] >= 0.999) { + ownBg = [bc[0], bc[1], bc[2]]; + break; + } } - if (rr.length === 0) continue; + var bgR, bgG, bgB; + if (ownBg) { + bgR = ownBg[0]; + bgG = ownBg[1]; + bgB = ownBg[2]; + } else { + // Sample 4px ring outside bbox for background color + var rr = [], + gg = [], + bb = []; + var x0 = Math.max(0, Math.floor(rect.x) - 4); + var x1 = Math.min(w - 1, Math.ceil(rect.x + rect.width) + 4); + var y0 = Math.max(0, Math.floor(rect.y) - 4); + var y1 = Math.min(h - 1, Math.ceil(rect.y + rect.height) + 4); + var sample = function (sx, sy) { + if (sx < 0 || sx >= w || sy < 0 || sy >= h) return; + var idx = (sy * w + sx) * 4; + rr.push(px[idx]); + gg.push(px[idx + 1]); + bb.push(px[idx + 2]); + }; + for (var x = x0; x <= x1; x++) { + sample(x, y0); + sample(x, y1); + } + for (var y = y0; y <= y1; y++) { + sample(x0, y); + sample(x1, y); + } - var bgR = median(rr), - bgG = median(gg), + if (rr.length === 0) continue; + + bgR = median(rr); + bgG = median(gg); bgB = median(bb); + } // Composite foreground alpha over measured background var compR = Math.round(fg[0] * fg[3] + bgR * (1 - fg[3])); diff --git a/packages/cli/src/commands/contrast-bg.test.ts b/packages/cli/src/commands/contrast-bg.test.ts new file mode 100644 index 000000000..8a58b3079 --- /dev/null +++ b/packages/cli/src/commands/contrast-bg.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { parseColorRGBA, pickOpaqueBackground } from "./contrast-bg.js"; + +const opaque = (bg: string) => ({ backgroundColor: bg, backgroundImage: "none" }); + +describe("parseColorRGBA", () => { + it("parses rgb() with default opaque alpha", () => { + expect(parseColorRGBA("rgb(12, 34, 56)")).toEqual([12, 34, 56, 1]); + }); + it("parses rgba() alpha", () => { + expect(parseColorRGBA("rgba(0, 0, 0, 0)")).toEqual([0, 0, 0, 0]); + }); + it("returns null for non-rgb strings", () => { + expect(parseColorRGBA("transparent")).toBeNull(); + expect(parseColorRGBA("")).toBeNull(); + expect(parseColorRGBA(null)).toBeNull(); + }); +}); + +describe("pickOpaqueBackground", () => { + it("returns the element's own opaque background-color", () => { + // The core bug: a caption/CTA with its own solid pill background. Its text + // is readable against that pill; the ring outside the box is irrelevant. + expect(pickOpaqueBackground([opaque("rgb(255, 255, 255)")])).toEqual([255, 255, 255]); + }); + + it("walks up to an opaque ancestor when the element itself is transparent", () => { + const chain = [ + opaque("rgba(0, 0, 0, 0)"), // element: transparent + opaque("rgb(20, 20, 20)"), // container: solid dark card behind the text + ]; + expect(pickOpaqueBackground(chain)).toEqual([20, 20, 20]); + }); + + it("defers to the ring (null) when a background-image is hit first", () => { + // Text over a real photo — the pixel ring is the right proxy, not a color. + const chain = [ + opaque("rgba(0, 0, 0, 0)"), + { backgroundColor: "rgb(20, 20, 20)", backgroundImage: 'url("photo.jpg")' }, + ]; + expect(pickOpaqueBackground(chain)).toBeNull(); + }); + + it("skips a semi-transparent background-color and keeps walking", () => { + const chain = [ + opaque("rgba(255, 255, 255, 0.4)"), // blends with below — not a reliable bg + opaque("rgb(10, 10, 10)"), + ]; + expect(pickOpaqueBackground(chain)).toEqual([10, 10, 10]); + }); + + it("returns null when nothing opaque exists (text truly over the scene)", () => { + expect(pickOpaqueBackground([opaque("rgba(0, 0, 0, 0)")])).toBeNull(); + }); +}); diff --git a/packages/cli/src/commands/contrast-bg.ts b/packages/cli/src/commands/contrast-bg.ts new file mode 100644 index 000000000..5aef85f7f --- /dev/null +++ b/packages/cli/src/commands/contrast-bg.ts @@ -0,0 +1,55 @@ +// Pure background-resolution logic for the WCAG contrast audit. +// +// The browser-side audit (contrast-audit.browser.js) samples a pixel ring just +// OUTSIDE an element's bounding box to estimate the background the text sits on. +// That is wrong for any element that paints its OWN opaque background (a caption +// pill, a CTA button, a solid card): the text is composited over that solid +// color, not over whatever surrounds the box. Sampling the ring there measures +// the text against the scene behind the element (often a dark photo) and reports +// a false ~1:1 ratio, flagging perfectly readable CTAs. +// +// This module hosts the pure decision so it can be unit-tested without a browser. +// The same logic is inlined into contrast-audit.browser.js (which is injected as +// a raw string and cannot import) — keep the two in sync, mirroring the existing +// "WCAG math is duplicated" note at the top of that file. + +export type Rgb = [number, number, number]; +export type Rgba = [number, number, number, number]; + +/** Parse a CSS `rgb()`/`rgba()` string. Returns null if it is not rgb(a). */ +export function parseColorRGBA(color: string | null | undefined): Rgba | null { + const body = /rgba?\(([^)]+)\)/.exec(color ?? "")?.[1]; + if (body == null) return null; + const p = body.split(",").map((s) => parseFloat(s.trim())); + if (p.length < 3 || p.some((n) => Number.isNaN(n))) return null; + const [r, g, b, a] = p; + if (r == null || g == null || b == null) return null; + return [r, g, b, a ?? 1]; +} + +/** One entry of an element's computed-style chain (element first, then ancestors). */ +export interface BackgroundStyle { + backgroundColor: string; + backgroundImage: string; +} + +/** + * Resolve the nearest FULLY-opaque background-color painted behind an element's + * text, walking from the element up its ancestor chain. + * + * Returns null (→ caller falls back to sampling the pixel ring) when: + * - a background-image is encountered first (text sits over real image pixels, + * for which the ring is the better proxy), or + * - no fully-opaque background-color exists in the chain. + * + * A semi-transparent background-color is skipped (it blends with whatever is + * below, which the ring captures better than any single color would). + */ +export function pickOpaqueBackground(chain: readonly BackgroundStyle[]): Rgb | null { + for (const s of chain) { + if (s.backgroundImage && s.backgroundImage !== "none") return null; + const c = parseColorRGBA(s.backgroundColor); + if (c && c[3] >= 0.999) return [c[0], c[1], c[2]]; + } + return null; +}