Skip to content
Draft
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
76 changes: 51 additions & 25 deletions packages/cli/src/commands/contrast-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]));
Expand Down
55 changes: 55 additions & 0 deletions packages/cli/src/commands/contrast-bg.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
53 changes: 53 additions & 0 deletions packages/cli/src/commands/contrast-bg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// 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 m = /rgba?\(([^)]+)\)/.exec(color ?? "");
if (!m) return null;
const p = m[1].split(",").map((s) => parseFloat(s.trim()));
if (p.length < 3 || p.some((n) => Number.isNaN(n))) return null;
return [p[0], p[1], p[2], p[3] != null ? p[3] : 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;
}
Loading