feat: add complete light and dark presets

This commit is contained in:
Simo committed 2026-07-12 14:45:11 +02:00
1 parent a550b06a58
commit 48c596cf41
2 files changed
+110 -21

No files matched your search

+33 -20
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { contrastRatio, derivePublicForegrounds, readableColor } from "@/lib/theme-contrast";
import { PRESETS } from "@/lib/theme-presets";
import { PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
describe("contrastRatio", () => {
it("calculates the WCAG ratio for black on white", () => {
@@ -13,27 +13,38 @@ describe("contrastRatio", () => {
});
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)) {
const backgrounds = [preset.color_background, preset.color_surface];
const foregrounds = [
readableColor(preset.color_text, backgrounds),
readableColor(preset.color_text_muted, backgrounds),
readableColor(preset.color_primary, backgrounds),
readableColor(preset.color_accent, backgrounds),
];
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 primaryForeground = readableColor(palette.button_text_color, [palette.color_primary]);
expect(contrastRatio(primaryForeground, palette.color_primary)).toBeGreaterThanOrEqual(4.5);
}
const buttonText = readableColor(preset.button_text_color, [preset.button_primary_color]);
expect(contrastRatio(buttonText, preset.button_primary_color)).toBeGreaterThanOrEqual(4.5);
const primaryForeground = readableColor(preset.button_text_color, [preset.color_primary]);
expect(contrastRatio(primaryForeground, preset.color_primary)).toBeGreaterThanOrEqual(4.5);
}
});
@@ -67,9 +78,11 @@ describe("readableColor", () => {
describe("derivePublicForegrounds", () => {
it.each(Object.entries(PRESETS))("makes every semantic pair readable for %s", (_name, preset) => {
const result = derivePublicForegrounds(preset);
for (const pair of result.pairs) {
expect(contrastRatio(pair.foreground, pair.background), pair.name).toBeGreaterThanOrEqual(4.5);
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);
}
}
});
+77 -1
View File
@@ -42,12 +42,27 @@ export const THEME_COLOR_KEYS = [
"color_info",
"button_primary_color",
"button_text_color",
"button_secondary_color",
"button_secondary_text_color",
"button_danger_color",
"button_danger_text_color",
"button_outline_color",
"button_outline_text_color",
"link_color",
"link_hover_color",
"border_color",
"gradient_from",
"gradient_to",
] as const;
export type ThemeMode = "light" | "dark";
export type ThemeColorKey = (typeof THEME_COLOR_KEYS)[number];
export type ThemePalette = Record<ThemeColorKey, string>;
export type ThemePreset = Record<ThemeMode, ThemePalette>;
// Curated palettes (faithful to the atom theme presets). Each is a full set of
// the colour keys above so applying one fully reskins the site.
export const PRESETS: Record<string, Record<string, string>> = {
const LEGACY_PALETTES: Record<string, Partial<ThemePalette>> = {
"Atom (golden)": {
color_primary: "#f59e0b",
color_background: "#f8fafc",
@@ -301,3 +316,64 @@ export const PRESETS: Record<string, Record<string, string>> = {
border_color: "#e8a33d",
},
};
const LIGHT_BASE: ThemePalette = {
color_primary: "#f59e0b", color_background: "#f8fafc", color_surface: "#ffffff",
color_dropdown: "#ffffff", color_navbar: "#ffffff", color_navbar_text: "#1e293b",
color_text: "#0f172a", color_text_muted: "#64748b", color_accent: "#10b981",
color_success: "#16a34a", color_warning: "#eab308", color_error: "#ef4444",
color_info: "#0ea5e9", button_primary_color: "#f59e0b", button_text_color: "#1e293b",
button_secondary_color: "#22c55e", button_secondary_text_color: "#ffffff",
button_danger_color: "#ef4444", button_danger_text_color: "#ffffff",
button_outline_color: "#eeb425", button_outline_text_color: "#1a1a2e",
link_color: "#b77900", link_hover_color: "#8a5b00", border_color: "#eeb425",
gradient_from: "#f59e0b", gradient_to: "#10b981",
};
const DARK_BASE: ThemePalette = {
...LIGHT_BASE,
color_background: "#0b0d14", color_surface: "#141820", color_dropdown: "#1d222b",
color_navbar: "#141820", color_navbar_text: "#e5e7eb", color_text: "#e5e7eb",
color_text_muted: "#94a3b8", button_text_color: "#0b0d14",
button_secondary_text_color: "#08130b", button_danger_text_color: "#160606",
button_outline_text_color: "#f8fafc", link_color: "#f6c453", link_hover_color: "#ffe08a",
};
const DARK_FIRST = new Set(["Midnight", "Galaxy", "Cyberpunk", "Neon", "Dusk"]);
function brandValues(source: Partial<ThemePalette>): Partial<ThemePalette> {
return {
color_primary: source.color_primary,
color_accent: source.color_accent,
color_success: source.color_success,
color_warning: source.color_warning,
color_error: source.color_error,
color_info: source.color_info,
button_primary_color: source.button_primary_color ?? source.color_primary,
border_color: source.border_color ?? source.color_primary,
gradient_from: source.color_primary,
gradient_to: source.color_accent,
button_outline_color: source.border_color ?? source.color_primary,
};
}
function completePalette(base: ThemePalette, values: Partial<ThemePalette>): ThemePalette {
const merged = { ...base, ...values };
merged.button_secondary_color = values.button_secondary_color ?? merged.color_accent;
merged.button_outline_color = values.button_outline_color ?? merged.border_color;
merged.gradient_from = values.gradient_from ?? merged.color_primary;
merged.gradient_to = values.gradient_to ?? merged.color_accent;
return merged;
}
export const PRESETS: Record<string, ThemePreset> = Object.fromEntries(
Object.entries(LEGACY_PALETTES).map(([name, palette]) => {
const light = DARK_FIRST.has(name)
? completePalette(LIGHT_BASE, brandValues(palette))
: completePalette(LIGHT_BASE, palette);
const dark = DARK_FIRST.has(name)
? completePalette(DARK_BASE, palette)
: completePalette(DARK_BASE, brandValues(palette));
return [name, { light, dark } satisfies ThemePreset];
}),
);