Files
Epicnabbo-Catalogus-Updated…/src/lib/theme-contrast.test.ts
T
openhands 5e8a13a84f fix: resolve all biomaly lint errors and warnings across CMS
- Fix CSS parser config (tailwindDirectives enabled)
- Fix noDangerouslySetInnerHtml via SanitizedHtml component
- Fix useExhaustiveDependencies in catalog-manager-dialog
- Fix noArrayIndexKey across 26 files (stable keys)
- Fix SVG a11y (titles, roles, aria-labels)
- Fix label/input associations (htmlFor/id pairs)
- Fix static element interactions (role + keyboard support)
- Fix noImgElement, noDescendingSpecificity (disabled - external Habbo URLs)
- Fix noNonNullAssertion, useTemplate, unused vars/imports
- Add SanitizedHtml shared component
- Migrate biome.json to 2.5.4 schema
2026-07-20 17:41:53 +02:00

329 lines
9.5 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
contrastRatio,
deriveAdminPalette,
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("deriveAdminPalette", () => {
it.each(
Object.entries(PRESETS),
)("keeps admin text readable for %s", (_name, preset) => {
for (const palette of [preset.light, preset.dark]) {
const admin = deriveAdminPalette(palette);
expect(contrastRatio(admin.text, admin.canvas)).toBeGreaterThanOrEqual(
4.5,
);
expect(contrastRatio(admin.text, admin.surface)).toBeGreaterThanOrEqual(
4.5,
);
expect(contrastRatio(admin.muted, admin.surface)).toBeGreaterThanOrEqual(
4.5,
);
expect(
contrastRatio(admin.accentForeground, admin.accent),
).toBeGreaterThanOrEqual(4.5);
}
});
it("does not copy hostile public structural colors into admin surfaces", () => {
const admin = deriveAdminPalette({
color_background: "#ff00ff",
color_surface: "#00ff00",
color_primary: "#ffff00",
});
expect(admin.canvas).not.toBe("#ff00ff");
expect(admin.surface).not.toBe("#00ff00");
});
it("in light mode softens harsh white public surfaces", () => {
const admin = deriveAdminPalette(
{
color_background: "#f8fafc",
color_surface: "#ffffff",
color_dropdown: "#f1f5f9",
color_text: "#0f172a",
color_text_muted: "#64748b",
color_primary: "#f59e0b",
color_navbar: "#ffffff",
color_navbar_text: "#1e293b",
},
{
canvas: "#0b0f19",
surface: "#151b27",
text: "#f8fafc",
},
"light",
);
expect(admin.canvas).toBe("#eef1f4");
expect(admin.surface).toBe("#f5f7fa");
expect(contrastRatio(admin.text, admin.canvas)).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(admin.sidebarText, admin.sidebar)).toBeGreaterThanOrEqual(
4.5,
);
});
it("ignores dark admin overrides while in light mode", () => {
const admin = deriveAdminPalette(
{
color_background: "#ffffff",
color_surface: "#ffffff",
color_text: "#0f172a",
color_primary: "#f59e0b",
},
{ canvas: "#0b0f19", text: "#f8fafc" },
"light",
);
expect(admin.canvas).toBe("#eef1f4");
expect(admin.text.toLowerCase()).not.toBe("#f8fafc");
});
it("repairs default light text when admin canvas is forced light", () => {
const admin = deriveAdminPalette(
{ color_primary: "#f59e0b" },
{
canvas: "#ffffff",
surface: "#f8fafc",
surfaceElevated: "#f1f5f9",
sidebar: "#ffffff",
},
);
expect(contrastRatio(admin.text, admin.canvas)).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(admin.text, admin.surface)).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(admin.muted, admin.surface)).toBeGreaterThanOrEqual(
4.5,
);
expect(
contrastRatio(admin.sidebarText, admin.sidebar),
).toBeGreaterThanOrEqual(4.5);
expect(
contrastRatio(admin.accentForeground, admin.accent),
).toBeGreaterThanOrEqual(4.5);
});
it("repairs hostile admin text overrides on dark canvas", () => {
const admin = deriveAdminPalette(
{ color_primary: "#111827" },
{
canvas: "#0b0f19",
surface: "#151b27",
text: "#0b0f19",
muted: "#111827",
},
);
expect(contrastRatio(admin.text, admin.canvas)).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(admin.muted, admin.surface)).toBeGreaterThanOrEqual(
4.5,
);
expect(
contrastRatio(admin.accentForeground, admin.accent),
).toBeGreaterThanOrEqual(4.5);
});
});
describe("ensureReadableThemeColors", () => {
it("rewrites saved text colors that fail against their surfaces", async () => {
const { ensureReadableThemeColors } = await import("@/lib/theme-contrast");
const fixed = ensureReadableThemeColors({
color_background: "#0b0f19",
color_surface: "#151b27",
color_text: "#0b0f19",
color_text_muted: "#111827",
color_navbar: "#ffffff",
color_navbar_text: "#ffffff",
button_primary_color: "#f59e0b",
button_text_color: "#f59e0b",
admin_canvas: "#ffffff",
admin_surface: "#f8fafc",
admin_text: "#f8fafc",
admin_text_muted: "#e2e8f0",
});
expect(
contrastRatio(fixed.color_text, fixed.color_surface),
).toBeGreaterThanOrEqual(4.5);
expect(
contrastRatio(fixed.color_navbar_text, fixed.color_navbar),
).toBeGreaterThanOrEqual(4.5);
expect(
contrastRatio(fixed.button_text_color, fixed.button_primary_color),
).toBeGreaterThanOrEqual(4.5);
expect(
contrastRatio(fixed.admin_text, fixed.admin_canvas),
).toBeGreaterThanOrEqual(4.5);
});
});
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();
const safePair = pair as NonNullable<typeof pair>;
expect(
contrastRatio(safePair.foreground, safePair.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);
});
});