mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-29 06:00:55 +02:00
164 lines
5.2 KiB
JavaScript
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);
|
|
});
|