From 4ce35e91fb3b3a6a1ef99074dd40be8dddc375fd Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 12 Jul 2026 18:47:23 +0200 Subject: [PATCH] feat: add semantic admin palette --- src/components/theme-vars.test.ts | 5 +++ src/lib/theme-contrast.test.ts | 22 ++++++++++++- src/lib/theme-contrast.ts | 52 +++++++++++++++++++++++++++++++ src/lib/theme-css.ts | 20 +++++++++++- 4 files changed, 97 insertions(+), 2 deletions(-) diff --git a/src/components/theme-vars.test.ts b/src/components/theme-vars.test.ts index e92156f6..c2f0ed50 100644 --- a/src/components/theme-vars.test.ts +++ b/src/components/theme-vars.test.ts @@ -18,6 +18,11 @@ describe("themePaletteCss", () => { "--admin-sidebar-text-readable", "--color-success-text-readable", "--color-success-foreground-readable", "--color-warning-text-readable", "--color-error-text-readable", "--color-info-text-readable", + "--admin-canvas", "--admin-surface", "--admin-surface-elevated", + "--admin-text", "--admin-text-muted", "--admin-border", + "--admin-accent", "--admin-accent-foreground", "--admin-overlay", + "--admin-focus-ring", "--admin-success", "--admin-warning", + "--admin-error", "--admin-info", ]) expect(css).toContain(variable); }); }); diff --git a/src/lib/theme-contrast.test.ts b/src/lib/theme-contrast.test.ts index 22327180..7111fbfb 100644 --- a/src/lib/theme-contrast.test.ts +++ b/src/lib/theme-contrast.test.ts @@ -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]) { diff --git a/src/lib/theme-contrast.ts b/src/lib/theme-contrast.ts index e663db77..c1beaac8 100644 --- a/src/lib/theme-contrast.ts +++ b/src/lib/theme-contrast.ts @@ -3,6 +3,58 @@ type Rgb = readonly [number, number, number]; export type PublicThemeColors = Record; 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"); diff --git a/src/lib/theme-css.ts b/src/lib/theme-css.ts index 3e3f02b1..71d87737 100644 --- a/src/lib/theme-css.ts +++ b/src/lib/theme-css.ts @@ -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 = { @@ -19,6 +19,7 @@ const CSS_VARIABLES: Record = { 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`]};`,