feat: add complete light and dark presets
This commit is contained in:
1 parent
a550b06a58
commit
48c596cf41
2 files changed
+101
-12
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { contrastRatio, derivePublicForegrounds, readableColor } from "@/lib/theme-contrast";
|
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", () => {
|
describe("contrastRatio", () => {
|
||||||
it("calculates the WCAG ratio for black on white", () => {
|
it("calculates the WCAG ratio for black on white", () => {
|
||||||
@@ -13,14 +13,24 @@ describe("contrastRatio", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("built-in theme presets", () => {
|
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", () => {
|
it("derives WCAG-readable public foregrounds for every palette", () => {
|
||||||
for (const preset of Object.values(PRESETS)) {
|
for (const preset of Object.values(PRESETS)) {
|
||||||
const backgrounds = [preset.color_background, preset.color_surface];
|
for (const palette of [preset.light, preset.dark]) {
|
||||||
|
const backgrounds = [palette.color_background, palette.color_surface];
|
||||||
const foregrounds = [
|
const foregrounds = [
|
||||||
readableColor(preset.color_text, backgrounds),
|
readableColor(palette.color_text, backgrounds),
|
||||||
readableColor(preset.color_text_muted, backgrounds),
|
readableColor(palette.color_text_muted, backgrounds),
|
||||||
readableColor(preset.color_primary, backgrounds),
|
readableColor(palette.color_primary, backgrounds),
|
||||||
readableColor(preset.color_accent, backgrounds),
|
readableColor(palette.color_accent, backgrounds),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const foreground of foregrounds) {
|
for (const foreground of foregrounds) {
|
||||||
@@ -29,11 +39,12 @@ describe("built-in theme presets", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const buttonText = readableColor(preset.button_text_color, [preset.button_primary_color]);
|
const buttonText = readableColor(palette.button_text_color, [palette.button_primary_color]);
|
||||||
expect(contrastRatio(buttonText, preset.button_primary_color)).toBeGreaterThanOrEqual(4.5);
|
expect(contrastRatio(buttonText, palette.button_primary_color)).toBeGreaterThanOrEqual(4.5);
|
||||||
|
|
||||||
const primaryForeground = readableColor(preset.button_text_color, [preset.color_primary]);
|
const primaryForeground = readableColor(palette.button_text_color, [palette.color_primary]);
|
||||||
expect(contrastRatio(primaryForeground, preset.color_primary)).toBeGreaterThanOrEqual(4.5);
|
expect(contrastRatio(primaryForeground, palette.color_primary)).toBeGreaterThanOrEqual(4.5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,10 +78,12 @@ describe("readableColor", () => {
|
|||||||
|
|
||||||
describe("derivePublicForegrounds", () => {
|
describe("derivePublicForegrounds", () => {
|
||||||
it.each(Object.entries(PRESETS))("makes every semantic pair readable for %s", (_name, preset) => {
|
it.each(Object.entries(PRESETS))("makes every semantic pair readable for %s", (_name, preset) => {
|
||||||
const result = derivePublicForegrounds(preset);
|
for (const palette of [preset.light, preset.dark]) {
|
||||||
|
const result = derivePublicForegrounds(palette);
|
||||||
for (const pair of result.pairs) {
|
for (const pair of result.pairs) {
|
||||||
expect(contrastRatio(pair.foreground, pair.background), pair.name).toBeGreaterThanOrEqual(4.5);
|
expect(contrastRatio(pair.foreground, pair.background), pair.name).toBeGreaterThanOrEqual(4.5);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("repairs hostile white-on-white custom colors", () => {
|
it("repairs hostile white-on-white custom colors", () => {
|
||||||
|
|||||||
@@ -42,12 +42,27 @@ export const THEME_COLOR_KEYS = [
|
|||||||
"color_info",
|
"color_info",
|
||||||
"button_primary_color",
|
"button_primary_color",
|
||||||
"button_text_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",
|
"border_color",
|
||||||
|
"gradient_from",
|
||||||
|
"gradient_to",
|
||||||
] as const;
|
] 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
|
// 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.
|
// 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)": {
|
"Atom (golden)": {
|
||||||
color_primary: "#f59e0b",
|
color_primary: "#f59e0b",
|
||||||
color_background: "#f8fafc",
|
color_background: "#f8fafc",
|
||||||
@@ -301,3 +316,64 @@ export const PRESETS: Record<string, Record<string, string>> = {
|
|||||||
border_color: "#e8a33d",
|
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