deadhead-space-freight-tycoon/apps/web/test/theme.test.ts

89 lines
2.9 KiB
TypeScript

import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const here = dirname(fileURLToPath(import.meta.url));
const css = readFileSync(join(here, "../src/index.css"), "utf8");
type Rgb = readonly [number, number, number];
/** A `--color-…` token from the theme as RGB, with its alpha if it has one. */
function token(name: string): { rgb: Rgb; alpha: number } {
const value = new RegExp(`--color-${name}: ([^;]+);`).exec(css)?.[1];
if (!value) throw new Error(`no token --color-${name}`);
if (value.startsWith("#")) {
const n = parseInt(value.slice(1), 16);
return { rgb: [(n >> 16) & 255, (n >> 8) & 255, n & 255], alpha: 1 };
}
const [r, g, b, a] = value.match(/[\d.]+/g)!.map(Number);
return { rgb: [r!, g!, b!], alpha: a ?? 1 };
}
const over = (top: Rgb, alpha: number, under: Rgb): Rgb =>
[0, 1, 2].map((i) => top[i]! * alpha + under[i]! * (1 - alpha)) as unknown as Rgb;
function luminance(rgb: Rgb): number {
const [r, g, b] = rgb.map((v) => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!;
}
function contrast(a: Rgb, b: Rgb): number {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi! + 0.05) / (lo! + 0.05);
}
const TEXT = [
"ink-50",
"ink-100",
"ink-200",
"ink-300",
"ink-400",
"ink-500",
"action",
"flight",
"earned",
"problem",
];
describe("theme (CR-029)", () => {
it("self-hosts the title and body fonts", () => {
expect(css).toMatch(/@import "@fontsource\/michroma\//);
expect(css).toMatch(/@import "@fontsource\/instrument-sans\//);
expect(css).toMatch(/--font-title: "Michroma"/);
expect(css).toMatch(/--font-sans: "Instrument Sans"/);
});
it("keeps the wide title face off labels and numbers", () => {
expect(css).toMatch(/--font-display: "Instrument Sans"/);
});
it("uses the plain ground behind screens, not a backdrop image", () => {
expect(css).not.toContain("bg.webp");
expect(css).toContain("background-color: var(--color-space-950)");
});
it("turns motion off under prefers-reduced-motion", () => {
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)/);
});
// A panel can sit over the Sun or a bright star, so white is the worst case.
it.each(TEXT)("%s reads at 4.5:1 on the glass over dark space and over white", (name) => {
const glass = token("glass");
const text = token(name).rgb;
for (const behind of [token("space-950").rgb, [255, 255, 255] as Rgb]) {
expect(contrast(text, over(glass.rgb, glass.alpha, behind))).toBeGreaterThanOrEqual(4.5);
}
});
it.each([
["on-action", "action"],
["on-problem", "problem"],
])("%s reads at 4.5:1 on %s", (text, fill) => {
expect(contrast(token(text).rgb, token(fill).rgb)).toBeGreaterThanOrEqual(4.5);
});
});