89 lines
2.9 KiB
TypeScript
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);
|
|
});
|
|
});
|