mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
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.
111 lines
4.0 KiB
TypeScript
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,
|
|
]);
|
|
});
|
|
});
|