diff --git a/src/lib/theme-contrast.test.ts b/src/lib/theme-contrast.test.ts index 5b14b5be..9f9281e3 100644 --- a/src/lib/theme-contrast.test.ts +++ b/src/lib/theme-contrast.test.ts @@ -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); + } } }); diff --git a/src/lib/theme-presets.ts b/src/lib/theme-presets.ts index f1ae87a5..e57ee7a2 100644 --- a/src/lib/theme-presets.ts +++ b/src/lib/theme-presets.ts @@ -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; +export type ThemePreset = Record; + // 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> = { +const LEGACY_PALETTES: Record> = { "Atom (golden)": { color_primary: "#f59e0b", color_background: "#f8fafc", @@ -301,3 +316,64 @@ export const PRESETS: Record> = { 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): Partial { + 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 { + 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 = 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]; + }), +);