Files
donutbrowser/src/lib/themes.test.mjs
T
2026-07-31 01:04:58 +04:00

164 lines
5.2 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
import Color from "color";
import {
getDerivedThemeColors,
getThemeByColors,
getThemeMode,
normalizeThemeColors,
THEMES,
} from "./themes.ts";
const AA_TEXT = 4.5;
const UI_BOUNDARY = 3;
const SEMANTIC_PAIRS = [
["background", "foreground"],
["card", "card-foreground"],
["popover", "popover-foreground"],
["primary", "primary-foreground"],
["secondary", "secondary-foreground"],
["muted", "muted-foreground"],
["accent", "accent-foreground"],
["destructive", "destructive-foreground"],
["success", "success-foreground"],
["warning", "warning-foreground"],
];
const CONTENT_SURFACES = ["background", "card", "popover", "muted"];
const CONTEXTUAL_ROLES = ["primary", "destructive", "success", "warning"];
const LIGHT_THEMES = new Set([
"ayu-light",
"catppuccin-latte",
"github-light",
"gruvbox-light",
"rose-pine-dawn",
"solarized-light",
]);
function contrast(foreground, background) {
return Color(foreground).contrast(Color(background));
}
function tintedSurface(surface, fill, opacity = 0.1) {
return Color(surface).mix(Color(fill), opacity);
}
function assertNoContrastFailures(failures) {
assert.deepEqual(failures, [], failures.join("\n"));
}
test("every theme declares the intended appearance mode", () => {
assert.equal(THEMES.length, 20);
assert.equal(new Set(THEMES.map((theme) => theme.id)).size, THEMES.length);
assert.deepEqual(
THEMES.filter((theme) => theme.mode === "light")
.map((theme) => theme.id)
.sort(),
[...LIGHT_THEMES].sort(),
);
for (const theme of THEMES) {
assert.equal(theme.mode, LIGHT_THEMES.has(theme.id) ? "light" : "dark");
assert.equal(getThemeMode(theme.colors), theme.mode);
}
});
test("every semantic fill and foreground pair meets WCAG AA", () => {
const failures = [];
for (const theme of THEMES) {
for (const [surface, content] of SEMANTIC_PAIRS) {
const ratio = contrast(
theme.colors[`--${content}`],
theme.colors[`--${surface}`],
);
if (ratio < AA_TEXT) {
failures.push(
`${theme.id}: ${surface}/${content} ${ratio.toFixed(2)}:1`,
);
}
}
}
assertNoContrastFailures(failures);
});
test("foreground and muted text remain readable on every content surface", () => {
const failures = [];
for (const theme of THEMES) {
for (const content of ["foreground", "muted-foreground"]) {
for (const surface of CONTENT_SURFACES) {
const ratio = contrast(
theme.colors[`--${content}`],
theme.colors[`--${surface}`],
);
if (ratio < AA_TEXT) {
failures.push(
`${theme.id}: ${content}/${surface} ${ratio.toFixed(2)}:1`,
);
}
}
}
}
assertNoContrastFailures(failures);
});
test("derived contextual text and control boundaries remain accessible", () => {
const failures = [];
for (const theme of THEMES) {
const derived = getDerivedThemeColors(theme.colors);
for (const role of CONTEXTUAL_ROLES) {
for (const surface of ["background", "card", "popover", "muted"]) {
const surfaceColor = theme.colors[`--${surface}`];
const contexts = [[surface, surfaceColor]];
if (surface !== "muted") {
contexts.push(
...[0.1, 0.2].map((opacity) => [
`${surface} with ${role} ${opacity * 100}% tint`,
tintedSurface(surfaceColor, theme.colors[`--${role}`], opacity),
]),
);
}
for (const [context, background] of contexts) {
const ratio = contrast(derived[`--${role}-text`], background);
if (ratio < AA_TEXT) {
failures.push(
`${theme.id}: ${role}-text/${context} ${ratio.toFixed(2)}:1`,
);
}
}
}
}
for (const boundary of ["--input", "--ring"]) {
for (const surface of ["background", "card", "popover", "muted"]) {
const ratio = contrast(derived[boundary], theme.colors[`--${surface}`]);
if (ratio < UI_BOUNDARY) {
failures.push(
`${theme.id}: ${boundary}/${surface} ${ratio.toFixed(2)}:1`,
);
}
}
}
}
assertNoContrastFailures(failures);
});
test("unmatched custom palettes derive their mode from the background", () => {
const darkCustom = { ...THEMES[0].colors, "--background": "#20242b" };
const lightCustom = { ...THEMES[0].colors, "--background": "#f4f1e8" };
assert.equal(getThemeMode(darkCustom), "dark");
assert.equal(getThemeMode(lightCustom), "light");
});
test("saved copies of the previous presets migrate without claiming edits", () => {
const houston = THEMES.find((theme) => theme.id === "houston");
const legacyHouston = {
...houston.colors,
"--secondary-foreground": "#f7f7f8",
"--accent-foreground": "#f7f7f8",
"--destructive-foreground": "#f7f7f8",
};
assert.equal(getThemeByColors(legacyHouston)?.id, "houston");
assert.deepEqual(normalizeThemeColors(legacyHouston), houston.colors);
const editedHouston = { ...legacyHouston, "--accent": "#123456" };
assert.equal(getThemeByColors(editedHouston), undefined);
assert.equal(normalizeThemeColors(editedHouston), editedHouston);
});