Files
hyperframes/packages/cli/src/commands/contrast-bg.test.ts
T
Miguel Angel Simon Sierra 7d6d41361b feat(cli): add check — single-session verification gate
One command, one Chrome boot: in-process lint gate (browser skipped on
lint errors), passive runtime capture wired before navigation, layout +
motion + contrast audits over one seek grid, optional --snapshots
persisting the contrast-pass screenshots. Aggregated --json envelope
{ok, lint, runtime, layout, motion, contrast, snapshots}; findings carry
selector/data-*/source-file/bbox/time anchors, contrast findings include
fg/bg, measured vs required ratio, and a compliant color suggestion.
Contrast AA failures gate the exit code (they were warning-only in
validate); --strict gates warnings.

Contrast candidates round-trip verbatim between __contrastAuditPrepare
and __contrastAuditFinish: the page script owns their shape (bbox w/h),
and normalizing them Node-side made every sample rect NaN — the audit
reported zero checked elements as green. Regression-pinned in
check.test.ts; E2E on a low-contrast fixture now exits 1 with 8 findings.

Measured on kinetic-type: check 5.6s vs 23.0s for sequential
validate + inspect + snapshot.
2026-07-10 13:27:52 -04:00

111 lines
4.0 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
contrastRatio,
parseColorRGBA,
pickOpaqueBackground,
relativeLuminance,
requiredContrastRatio,
suggestCompliantForegroundColor,
} 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();
});
});
describe("relativeLuminance", () => {
it("uses the WCAG sRGB transfer function", () => {
expect(relativeLuminance([0, 0, 0])).toBe(0);
expect(relativeLuminance([255, 255, 255])).toBe(1);
expect(relativeLuminance([255, 0, 0])).toBeCloseTo(0.2126, 4);
});
});
describe("contrastRatio", () => {
it("is symmetric and reaches 21:1 for black and white", () => {
expect(contrastRatio([0, 0, 0], [255, 255, 255])).toBe(21);
expect(contrastRatio([255, 255, 255], [0, 0, 0])).toBe(21);
});
});
describe("requiredContrastRatio", () => {
it("requires 3:1 for large text and 4.5:1 otherwise", () => {
expect(requiredContrastRatio(true)).toBe(3);
expect(requiredContrastRatio(false)).toBe(4.5);
});
});
describe("suggestCompliantForegroundColor", () => {
it("brightens a failing foreground on a dark background until it passes", () => {
const background: [number, number, number] = [20, 20, 20];
const foreground: [number, number, number] = [80, 80, 80];
const suggested = suggestCompliantForegroundColor(foreground, background, 4.5);
expect(suggested[0]).toBeGreaterThan(foreground[0]);
expect(contrastRatio(suggested, background)).toBeGreaterThanOrEqual(4.5);
});
it("darkens a failing foreground on a light background until it passes", () => {
const background: [number, number, number] = [245, 245, 245];
const foreground: [number, number, number] = [180, 180, 180];
const suggested = suggestCompliantForegroundColor(foreground, background, 4.5);
expect(suggested[0]).toBeLessThan(foreground[0]);
expect(contrastRatio(suggested, background)).toBeGreaterThanOrEqual(4.5);
});
it("preserves a foreground that already passes", () => {
expect(suggestCompliantForegroundColor([255, 255, 255], [0, 0, 0], 4.5)).toEqual([
255, 255, 255,
]);
});
});