feat: add semantic admin palette

This commit is contained in:
Simo committed 2026-07-12 18:47:23 +02:00
1 parent d4bcf89449
commit 4ce35e91fb
4 files changed
+97 -2

No files matched your search

+21 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { contrastRatio, derivePublicForegrounds, readableColor } from "@/lib/theme-contrast";
import { contrastRatio, deriveAdminPalette, derivePublicForegrounds, readableColor } from "@/lib/theme-contrast";
import { PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
describe("contrastRatio", () => {
@@ -76,6 +76,26 @@ describe("readableColor", () => {
});
});
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("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]) {
+52
View File
@@ -3,6 +3,58 @@ type Rgb = readonly [number, number, number];
export type PublicThemeColors = Record<string, string>;
export interface ContrastPair { name: string; foreground: string; background: string }
export interface AdminPalette {
canvas: string;
surface: string;
surfaceElevated: string;
text: string;
muted: string;
border: string;
accent: string;
accentText: string;
accentForeground: string;
success: string;
warning: string;
error: string;
info: string;
sidebar: string;
sidebarText: string;
overlay: string;
focusRing: string;
}
export function deriveAdminPalette(colors: PublicThemeColors): AdminPalette {
const publicBackground = colors.color_background ?? "#f8fafc";
const blackRatio = contrastRatio("#000000", publicBackground) ?? 21;
const whiteRatio = contrastRatio("#ffffff", publicBackground) ?? 1;
const dark = whiteRatio > blackRatio;
const canvas = dark ? "#0b0f19" : "#f1f5f9";
const surface = dark ? "#151b27" : "#ffffff";
const surfaceElevated = dark ? "#1e2635" : "#f8fafc";
const text = dark ? "#f8fafc" : "#0f172a";
const muted = dark ? "#a8b3c7" : "#475569";
const accent = colors.color_primary ?? "#f59e0b";
return {
canvas,
surface,
surfaceElevated,
text: readableColor(text, [canvas, surface, surfaceElevated]),
muted: readableColor(muted, [canvas, surface, surfaceElevated]),
border: dark ? "#334155" : "#cbd5e1",
accent,
accentText: readableColor(accent, [canvas, surface, surfaceElevated]),
accentForeground: readableColor("#ffffff", [accent]),
success: dark ? "#4ade80" : "#15803d",
warning: dark ? "#facc15" : "#a16207",
error: dark ? "#f87171" : "#b91c1c",
info: dark ? "#38bdf8" : "#0369a1",
sidebar: dark ? "#101622" : "#172033",
sidebarText: "#f8fafc",
overlay: "rgba(2, 6, 23, 0.72)",
focusRing: accent,
};
}
export function derivePublicForegrounds(c: PublicThemeColors) {
const value = (key: string, fallback: string) => c[key] ?? fallback;
const background = value("color_background", "#f8fafc");
+19 -1
View File
@@ -1,4 +1,4 @@
import { derivePublicForegrounds, readableColor } from "@/lib/theme-contrast";
import { deriveAdminPalette, derivePublicForegrounds, readableColor } from "@/lib/theme-contrast";
import type { ThemePalette } from "@/lib/theme-presets";
const CSS_VARIABLES: Record<keyof ThemePalette, string> = {
@@ -19,6 +19,7 @@ const CSS_VARIABLES: Record<keyof ThemePalette, string> = {
export function themePaletteCss(selector: string, palette: ThemePalette): string {
const semantic = derivePublicForegrounds(palette);
const admin = deriveAdminPalette(palette);
const declarations = Object.entries(CSS_VARIABLES).map(
([key, cssName]) => `--${cssName}:${palette[key as keyof ThemePalette]};`,
);
@@ -47,6 +48,23 @@ export function themePaletteCss(selector: string, palette: ThemePalette): string
`--admin-sidebar-text-readable:${semantic.byName.sidebar};`,
`--admin-sidebar-muted-readable:${semantic.byName.sidebar};`,
`--admin-input-text-readable:${semantic.byName.input};`,
`--admin-canvas:${admin.canvas};`,
`--admin-surface:${admin.surface};`,
`--admin-surface-elevated:${admin.surfaceElevated};`,
`--admin-text:${admin.text};`,
`--admin-text-muted:${admin.muted};`,
`--admin-border:${admin.border};`,
`--admin-accent:${admin.accent};`,
`--admin-accent-text:${admin.accentText};`,
`--admin-accent-foreground:${admin.accentForeground};`,
`--admin-success:${admin.success};`,
`--admin-warning:${admin.warning};`,
`--admin-error:${admin.error};`,
`--admin-info:${admin.info};`,
`--admin-sidebar:${admin.sidebar};`,
`--admin-sidebar-text:${admin.sidebarText};`,
`--admin-overlay:${admin.overlay};`,
`--admin-focus-ring:${admin.focusRing};`,
...(["success", "warning", "error", "info"] as const).flatMap((status) => [
`--color-${status}-text-readable:${semantic.byName[`${status}-text`]};`,
`--color-${status}-foreground-readable:${semantic.byName[`${status}-solid`]};`,