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
352 changes: 250 additions & 102 deletions packages/cli/src/commands/contrast-audit.browser.js

Large diffs are not rendered by default.

26 changes: 15 additions & 11 deletions packages/cli/src/commands/contrast-bg.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
// 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.
// HISTORICAL NOTE: contrast-audit.browser.js used to sample a 4px pixel ring
// just OUTSIDE an element's bounding box to estimate its background, with
// pickOpaqueBackground() below as a pre-check: prefer an element's own opaque
// background-color over the ring for solid CTA/pill/card cases. The audit has
// since moved to sampling the ACTUAL composited pixels directly inside each
// element's own bbox (hiding the glyphs first) — see contrast-sample.ts — which
// gets pill/card backgrounds right AND fixes the ring's blind spots (rounded
// corners, backdrop-filter blur, cross-component bleed, partially-overlapping
// translucent decoration). pickOpaqueBackground()/parseColorRGBA() are no
// longer called by the live audit but are kept here — still correct, still
// unit-tested, and contrast-fg.ts imports parseColorRGBA from this module.
//
// 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.
// This module hosts pure decisions so they can be unit-tested without a
// browser. Historically the same logic was inlined into
// contrast-audit.browser.js (which is injected as a raw string and cannot
// import) — see contrast-sample.ts for what's actually mirrored today.

export type Rgb = [number, number, number];
export type Rgba = [number, number, number, number];
Expand Down
61 changes: 61 additions & 0 deletions packages/cli/src/commands/contrast-fg.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { resolveForegroundColor } from "./contrast-fg.js";

describe("resolveForegroundColor", () => {
it("uses `color` for ordinary HTML text", () => {
expect(
resolveForegroundColor({
isSvgText: false,
fill: "rgb(255, 255, 255)",
color: "rgb(0, 0, 0)",
}),
).toEqual([0, 0, 0, 1]);
});

it("uses `fill` for SVG text when fill is set but color is not (the reported bug)", () => {
// fill: white on a dark bg, no `color` set — getComputedStyle(el).color
// resolves to the inherited/initial black, which does not match what's
// actually rendered. The audit must read `fill`, not `color`, here.
expect(
resolveForegroundColor({
isSvgText: true,
fill: "rgb(255, 255, 255)",
color: "rgb(0, 0, 0)",
}),
).toEqual([255, 255, 255, 1]);
});

it("preserves fill alpha", () => {
expect(
resolveForegroundColor({
isSvgText: true,
fill: "rgba(10, 20, 30, 0.5)",
color: "rgb(0, 0, 0)",
}),
).toEqual([10, 20, 30, 0.5]);
});

it("falls back to `color` when fill is 'none'", () => {
expect(
resolveForegroundColor({ isSvgText: true, fill: "none", color: "rgb(0, 255, 0)" }),
).toEqual([0, 255, 0, 1]);
});

it("falls back to `color` when fill is 'context-fill'", () => {
expect(
resolveForegroundColor({ isSvgText: true, fill: "context-fill", color: "rgb(0, 255, 0)" }),
).toEqual([0, 255, 0, 1]);
});

it("falls back to `color` when fill is a gradient/pattern reference", () => {
expect(
resolveForegroundColor({ isSvgText: true, fill: 'url("#grad")', color: "rgb(0, 255, 0)" }),
).toEqual([0, 255, 0, 1]);
});

it("never crashes on garbage fill values", () => {
expect(() =>
resolveForegroundColor({ isSvgText: true, fill: "currentcolor", color: "rgb(1, 2, 3)" }),
).not.toThrow();
});
});
42 changes: 42 additions & 0 deletions packages/cli/src/commands/contrast-fg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Pure foreground-color resolution logic for the WCAG contrast audit.
//
// The browser-side audit (contrast-audit.browser.js) reads an element's
// foreground paint color from getComputedStyle(el).color. That is correct for
// ordinary HTML text, but SVG text (<text>, <tspan>, <textPath>) is painted
// via the `fill` property — `fill` and `color` are independent CSS properties
// in SVG. A page can set `fill` (inline style, a `fill` attribute, or a CSS
// rule) without ever touching `color`, in which case `color` resolves to the
// inherited/initial value (often black) and does not reflect what's actually
// rendered on screen. That mismatch was reported as a real false pass/fail
// in the contrast audit.
//
// 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) and into
// skills/hyperframes-creative/scripts/contrast-report.mjs — keep all three in
// sync, mirroring the existing "WCAG math is duplicated" note at the top of
// contrast-audit.browser.js.

import { parseColorRGBA, type Rgba } from "./contrast-bg.js";

/**
* Resolve the foreground paint color for a text-bearing element.
*
* - For ordinary HTML text, this is always the computed `color`.
* - For SVG text, the rendered glyph color is `fill`, not `color`. `fill` is
* only trusted when it resolves to a solid rgb()/rgba() color — SVG paint
* keywords ("none", "context-fill") and gradient/pattern references
* (`url(#...)`) are not colors, so those fall back to `color` instead of
* fabricating black.
*/
export function resolveForegroundColor(opts: {
isSvgText: boolean;
fill: string;
color: string;
}): Rgba {
if (opts.isSvgText) {
const solid = parseColorRGBA(opts.fill);
if (solid) return solid;
}
return parseColorRGBA(opts.color) ?? [0, 0, 0, 1];
}
64 changes: 64 additions & 0 deletions packages/cli/src/commands/contrast-sample.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { computeSampleRect, sampleGridPoints } from "./contrast-sample.js";

describe("computeSampleRect", () => {
it("insets 1px on each side of a normal bbox", () => {
expect(computeSampleRect({ x: 10, y: 20, w: 30, h: 40 }, 640, 480)).toEqual({
x0: 11,
x1: 39,
y0: 21,
y1: 59,
});
});

it("clamps the left/top edge to 0 when the bbox starts off-canvas", () => {
expect(computeSampleRect({ x: -5, y: -5, w: 20, h: 20 }, 640, 480)).toEqual({
x0: 0,
x1: 14,
y0: 0,
y1: 14,
});
});

it("clamps the right/bottom edge to the canvas bounds", () => {
expect(computeSampleRect({ x: 620, y: 460, w: 40, h: 40 }, 640, 480)).toEqual({
x0: 621,
x1: 639,
y0: 461,
y1: 479,
});
});

it("returns null when the bbox is too small to survive the 1px inset", () => {
// width 2 → x0 = x+1, x1 = x+1 - 1 = x → x1 <= x0
expect(computeSampleRect({ x: 100, y: 100, w: 2, h: 2 }, 640, 480)).toBeNull();
});

it("returns null when the bbox is entirely outside the canvas", () => {
expect(computeSampleRect({ x: 1000, y: 1000, w: 20, h: 20 }, 640, 480)).toBeNull();
});
});

describe("sampleGridPoints", () => {
it("covers the full rect, starting at its top-left interior corner", () => {
const points = sampleGridPoints({ x0: 10, x1: 22, y0: 10, y1: 16 }, 12, 6);
expect(points[0]).toEqual([10, 10]);
for (const [x, y] of points) {
expect(x).toBeGreaterThanOrEqual(10);
expect(x).toBeLessThanOrEqual(22);
expect(y).toBeGreaterThanOrEqual(10);
expect(y).toBeLessThanOrEqual(16);
}
});

it("caps the point count for a large rect instead of scanning every pixel", () => {
const points = sampleGridPoints({ x0: 0, x1: 1199, y0: 0, y1: 59 }, 12, 6);
// (maxCols+1) * (maxRows+1) upper bound — nowhere near a full 1200x60 scan.
expect(points.length).toBeLessThan(13 * 7 + 5);
});

it("still returns at least one point for a rect narrower than the grid step", () => {
const points = sampleGridPoints({ x0: 5, x1: 6, y0: 5, y1: 6 }, 12, 6);
expect(points.length).toBeGreaterThan(0);
});
});
89 changes: 89 additions & 0 deletions packages/cli/src/commands/contrast-sample.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Pure background-sampling-region logic for the WCAG contrast audit.
//
// The audit used to estimate an element's background by sampling a 4px ring
// just OUTSIDE its bounding box (with an own-opaque-background pre-check —
// see the historical note in contrast-bg.ts). That proximity-based estimate
// breaks down whenever what's immediately outside the box differs from
// what's actually behind the text inside it:
//
// - cross-component bleed: text sits near the edge of its own panel/layer,
// and a differently-colored sibling panel/layer starts just outside the
// text's bbox — the ring samples the neighbor, not the true background.
// - solid-fill pill/button with rounded corners achieved via a sibling
// shape (not a CSS background-color on an ancestor of the text) — same
// failure as above; the ownBg ancestor-walk never sees it.
// - translucent "glass" text over a backdrop-filter blur panel sized only
// a couple pixels larger than the text — the ring exits the panel and
// samples the raw, unblurred, untinted pixels behind it.
// - a translucent/decorative shape that only partially overlaps the ring,
// or sits entirely INSIDE the text's own bbox (never touching the ring
// at all) — the ring is structurally blind to it.
//
// The fix: hide the text's own paint (color/fill → transparent), take ONE
// screenshot with the glyphs invisible, then sample the REAL composited
// pixels directly INSIDE the element's own bbox — no proximity heuristic
// needed, because we're reading the exact pixels that were behind the
// glyphs. This module hosts the pure "which rect do we sample" decision
// (inset to dodge anti-aliased edge pixels, clamp to canvas bounds, and
// reject a rect that's too small to sample) so it's unit-testable without a
// browser. The same logic is inlined into contrast-audit.browser.js (which
// is injected as a raw string and cannot import) and into
// skills/hyperframes-creative/scripts/contrast-report.mjs — keep all three
// in sync.

export interface Rect {
x: number;
y: number;
w: number;
h: number;
}

export interface PixelRect {
x0: number;
x1: number;
y0: number;
y1: number;
}

/**
* Compute the region to sample for an element's true background: its own
* bbox, inset by 1px on each side (anti-aliased glyph/box edges bleed into
* the adjacent pixel and shouldn't count as "background"), clamped to the
* screenshot's pixel bounds.
*
* Returns null when nothing usable survives — the bbox is entirely outside
* the canvas, or is too small once inset to contain any interior pixel.
*/
export function computeSampleRect(
bbox: Rect,
canvasWidth: number,
canvasHeight: number,
): PixelRect | null {
const x0 = Math.max(0, Math.round(bbox.x) + 1);
const x1 = Math.min(canvasWidth - 1, Math.round(bbox.x + bbox.w) - 1);
const y0 = Math.max(0, Math.round(bbox.y) + 1);
const y1 = Math.min(canvasHeight - 1, Math.round(bbox.y + bbox.h) - 1);
if (x1 <= x0 || y1 <= y0) return null;
return { x0, x1, y0, y1 };
}

/**
* Generate a bounded grid of sample coordinates within a pixel rect — dense
* enough to catch a partially-overlapping decoration, capped so a large
* caption bar doesn't turn into a full pixel scan.
*/
export function sampleGridPoints(
rect: PixelRect,
maxCols = 12,
maxRows = 6,
): Array<[number, number]> {
const stepX = Math.max(1, Math.floor((rect.x1 - rect.x0) / maxCols));
const stepY = Math.max(1, Math.floor((rect.y1 - rect.y0) / maxRows));
const points: Array<[number, number]> = [];
for (let y = rect.y0; y <= rect.y1; y += stepY) {
for (let x = rect.x0; x <= rect.x1; x += stepX) {
points.push([x, y]);
}
}
return points;
}
Loading
Loading