Files
EpicNext-Cms/src/lib/theme-contrast.test.ts
T
Simo f0b4bc1630
Local Build and Deploy / deploy (push) Successful in 44s
fix: enforce semantic contrast across admin
2026-07-12 15:14:15 +02:00

122 lines
5.2 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { contrastRatio, derivePublicForegrounds, readableColor } from "@/lib/theme-contrast";
import { PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
describe("contrastRatio", () => {
it("calculates the WCAG ratio for black on white", () => {
expect(contrastRatio("#000000", "#ffffff")).toBe(21);
});
it("returns null for unsupported color formats", () => {
expect(contrastRatio("var(--color-text)", "#ffffff")).toBeNull();
});
});
describe("built-in theme presets", () => {
it("defines every theme color in light and dark mode", () => {
for (const preset of Object.values(PRESETS)) {
expect(Object.keys(preset).sort()).toEqual(["dark", "light"]);
for (const palette of [preset.light, preset.dark]) {
expect(Object.keys(palette).sort()).toEqual([...THEME_COLOR_KEYS].sort());
}
}
});
it("derives WCAG-readable public foregrounds for every palette", () => {
for (const preset of Object.values(PRESETS)) {
for (const palette of [preset.light, preset.dark]) {
const backgrounds = [palette.color_background, palette.color_surface];
const foregrounds = [
readableColor(palette.color_text, backgrounds),
readableColor(palette.color_text_muted, backgrounds),
readableColor(palette.color_primary, backgrounds),
readableColor(palette.color_accent, backgrounds),
];
for (const foreground of foregrounds) {
for (const background of backgrounds) {
expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(4.5);
}
}
const buttonText = readableColor(palette.button_text_color, [palette.button_primary_color]);
expect(contrastRatio(buttonText, palette.button_primary_color)).toBeGreaterThanOrEqual(4.5);
const primaryForeground = readableColor(palette.button_text_color, [palette.color_primary]);
expect(contrastRatio(primaryForeground, palette.color_primary)).toBeGreaterThanOrEqual(4.5);
}
}
});
it.each(["#22c55e", "#ef4444"])("derives readable text for default button %s", (background) => {
const foreground = readableColor("#ffffff", [background]);
expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(4.5);
});
});
describe("readableColor", () => {
it("keeps the preferred color when it passes on every background", () => {
expect(readableColor("#0f172a", ["#ffffff", "#f8fafc"], 4.5)).toBe("#0f172a");
});
it("replaces low-contrast golden text with a readable neutral", () => {
const color = readableColor("#f59e0b", ["#ffffff", "#f8fafc"], 4.5);
expect(color).toBe("#000000");
expect(contrastRatio(color, "#ffffff")).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(color, "#f8fafc")).toBeGreaterThanOrEqual(4.5);
});
it("chooses white text for a dark button when configured text fails", () => {
expect(readableColor("#777777", ["#111827"], 4.5)).toBe("#ffffff");
});
it("preserves unsupported custom colors instead of corrupting CSS", () => {
expect(readableColor("rgb(1, 2, 3)", ["#ffffff"], 4.5)).toBe("rgb(1, 2, 3)");
});
});
describe("derivePublicForegrounds", () => {
it.each(Object.entries(PRESETS))("makes every semantic pair readable for %s", (_name, preset) => {
for (const palette of [preset.light, preset.dark]) {
const result = derivePublicForegrounds(palette);
for (const pair of result.pairs) {
expect(contrastRatio(pair.foreground, pair.background), pair.name).toBeGreaterThanOrEqual(4.5);
}
}
});
it("covers admin hierarchy and every semantic status", () => {
const required = [
"subtle", "disabled", "sidebar", "input",
"success-text", "success-solid", "warning-text", "warning-solid",
"error-text", "error-solid", "info-text", "info-solid",
];
for (const preset of Object.values(PRESETS)) {
for (const palette of [preset.light, preset.dark]) {
const result = derivePublicForegrounds(palette);
for (const name of required) {
const pair = result.pairs.find((candidate) => candidate.name === name);
expect(pair, `missing ${name}`).toBeDefined();
expect(contrastRatio(pair!.foreground, pair!.background), name).toBeGreaterThanOrEqual(4.5);
}
}
}
});
it("repairs hostile white-on-white custom colors", () => {
const result = derivePublicForegrounds({
color_background: "#ffffff", color_surface: "#ffffff", color_dropdown: "#ffffff",
color_navbar: "#ffffff", color_navbar_text: "#ffffff", color_text: "#ffffff",
color_text_muted: "#ffffff", color_primary: "#ffffff", color_accent: "#ffffff",
button_primary_color: "#ffffff", button_text_color: "#ffffff",
button_secondary_color: "#ffffff", button_secondary_text_color: "#ffffff",
button_danger_color: "#ffffff", button_danger_text_color: "#ffffff",
button_outline_color: "#ffffff", button_outline_text_color: "#ffffff",
link_color: "#ffffff", link_hover_color: "#ffffff",
});
expect(result.pairs.length).toBeGreaterThan(10);
for (const pair of result.pairs) expect(contrastRatio(pair.foreground, pair.background), pair.name).toBeGreaterThanOrEqual(4.5);
});
});