feat: add complete light and dark presets
This commit is contained in:
1 parent
a550b06a58
commit
48c596cf41
2 files changed
+110
-21
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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];
|
||||
}),
|
||||
);
|
||||
Reference in new issue
Block a user