This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
+179
-137
@@ -1,169 +1,211 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
contrastRatio,
|
||||
deriveAdminPalette,
|
||||
derivePublicForegrounds,
|
||||
readableColor,
|
||||
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("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();
|
||||
});
|
||||
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("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),
|
||||
];
|
||||
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);
|
||||
}
|
||||
}
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
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);
|
||||
});
|
||||
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("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);
|
||||
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);
|
||||
});
|
||||
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("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)");
|
||||
});
|
||||
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.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("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");
|
||||
});
|
||||
});
|
||||
|
||||
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.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("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);
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user