diff --git a/src/actions/admin-theme.ts b/src/actions/admin-theme.ts index d2b56ffe..e57bca8f 100644 --- a/src/actions/admin-theme.ts +++ b/src/actions/admin-theme.ts @@ -34,6 +34,12 @@ export async function saveTheme(formData: FormData): Promise { if (raw && COLOR_RE.test(raw)) await writeSetting(dbKey, raw); } } + const ADMIN_KEYS = ["admin_canvas", "admin_surface", "admin_text", "admin_text_muted", "admin_border", "admin_sidebar_bg"]; + for (const key of ADMIN_KEYS) { + const raw = String(formData.get(key) ?? "").normalize("NFC").trim(); + if (raw && COLOR_RE.test(raw)) await writeSetting(key, raw); + } + const radius = String(formData.get("border_radius") ?? "").normalize("NFC").trim(); if (/^\d{1,3}$/.test(radius)) await writeSetting("border_radius", radius); diff --git a/src/app/admin/theme/page.tsx b/src/app/admin/theme/page.tsx index 5e8e0f35..707252e4 100644 --- a/src/app/admin/theme/page.tsx +++ b/src/app/admin/theme/page.tsx @@ -47,6 +47,15 @@ const HEADINGS: { key: string; label: string; def: string }[] = [ { key: "size_heading_h3", label: "H3", def: "18" }, ]; +const ADMIN_FIELDS: { key: string; label: string; def: string }[] = [ + { key: "admin_canvas", label: "Admin canvas", def: "#0f172a" }, + { key: "admin_surface", label: "Admin surface", def: "#1e293b" }, + { key: "admin_text", label: "Admin text", def: "#f1f5f9" }, + { key: "admin_text_muted", label: "Admin muted text", def: "#94a3b8" }, + { key: "admin_border", label: "Admin border", def: "#334155" }, + { key: "admin_sidebar_bg", label: "Admin sidebar", def: "#0f172a" }, +]; + const ALL_COLORS = [...COLOR_FIELDS, ...BUTTON_FIELDS, ...GRADIENT_FIELDS]; function toHex(value: string | null, def: string): string { @@ -92,7 +101,9 @@ export default async function AdminTheme({ const current = Object.fromEntries( await Promise.all( - ALL_COLORS.map(async (f) => [f.key, (await siteSettings.get(f.key, f.def)) ?? f.def] as const), + [...ALL_COLORS, ...ADMIN_FIELDS].map( + async (f) => [f.key, (await siteSettings.get(f.key, f.def)) ?? f.def] as const, + ), ), ) as Record; const defaultPreset = PRESETS["Atom (golden)"]; @@ -190,6 +201,14 @@ export default async function AdminTheme({
{colorGrid(GRADIENT_FIELDS, current)}
+

Admin panel (HK)

+
+

+ Colors for the Housekeeping admin panel. These override the defaults derived from light/dark mode palettes. +

+
{colorGrid(ADMIN_FIELDS, current)}
+
+

Dark mode

{t("colours")}

diff --git a/src/components/theme-vars.tsx b/src/components/theme-vars.tsx index c2b34140..a8d04428 100644 --- a/src/components/theme-vars.tsx +++ b/src/components/theme-vars.tsx @@ -1,7 +1,7 @@ import { siteSettings } from "@/lib/services/site-settings"; -import { themePaletteCss } from "@/lib/theme-css"; +import { adminPaletteCss, themePaletteCss } from "@/lib/theme-css"; import { FONTS, PRESETS, THEME_COLOR_KEYS, type ThemePalette } from "@/lib/theme-presets"; -import { derivePublicForegrounds, readableColor } from "@/lib/theme-contrast"; +import { deriveAdminPalette, derivePublicForegrounds, readableColor } from "@/lib/theme-contrast"; // Injects the DB-driven CSS custom properties into :root, exactly like // AtomCMS's app.blade.php. Falls back to the atom defaults when no DB. Covers @@ -41,6 +41,12 @@ export async function ThemeVars() { h2, h3, customCss, + adminCanvas, + adminSurface, + adminText, + adminTextMuted, + adminBorder, + adminSidebarBg, ] = await Promise.all([ g("color_primary", "#f59e0b"), g("color_background", "#f8fafc"), @@ -74,6 +80,12 @@ export async function ThemeVars() { g("size_heading_h2", "22"), g("size_heading_h3", "18"), g("custom_css", ""), + g("admin_canvas", ""), + g("admin_surface", ""), + g("admin_text", ""), + g("admin_text_muted", ""), + g("admin_border", ""), + g("admin_sidebar_bg", ""), ]); // Only hex/keyword color values are admin-controlled — sanitize defensively. @@ -99,6 +111,15 @@ export async function ThemeVars() { const readableNavbarText = readableColor(safe(navbarText, "#1e293b"), [safe(navbar, "#ffffff")]); const readablePrimaryForeground = readableColor(safe(buttonText, "#1e293b"), [safe(primary, "#f59e0b")]); const readableAccentForeground = readableColor("#ffffff", [safe(accent, "#10b981")]); + const adminOverrides = { + ...(adminCanvas ? { canvas: safe(adminCanvas, "") } : {}), + ...(adminSurface ? { surface: safe(adminSurface, "") } : {}), + ...(adminText ? { text: safe(adminText, "") } : {}), + ...(adminTextMuted ? { muted: safe(adminTextMuted, "") } : {}), + ...(adminBorder ? { border: safe(adminBorder, "") } : {}), + ...(adminSidebarBg ? { sidebar: safe(adminSidebarBg, "") } : {}), + }; + const semantic = derivePublicForegrounds({ color_background: safeBackground, color_surface: safeSurface, @@ -132,9 +153,22 @@ export async function ThemeVars() { ), ), ) as ThemePalette; - const darkCss = themePaletteCss("html.dark", darkPalette); + const darkCss = themePaletteCss("html.dark", darkPalette, adminOverrides); - const css = `:root{ + const lightAdmin = deriveAdminPalette({ + color_background: safeBackground, + color_surface: safeSurface, + color_text: safe(text, "#0f172a"), + color_text_muted: safe(textMuted, "#64748b"), + color_primary: safe(primary, "#f59e0b"), + color_accent: safe(accent, "#10b981"), + color_success: safe(success, "#16a34a"), + color_warning: safe(warning, "#eab308"), + color_error: safe(error, "#ef4444"), + color_info: safe(info, "#0ea5e9"), + } as ThemePalette, adminOverrides); + + const css = `:root{${adminPaletteCss(lightAdmin)} --color-primary:${safe(primary, "#f59e0b")}; --color-background:${safeBackground}; --color-surface:${safeSurface}; diff --git a/src/lib/theme-contrast.ts b/src/lib/theme-contrast.ts index 7ff09ccb..ff43388d 100644 --- a/src/lib/theme-contrast.ts +++ b/src/lib/theme-contrast.ts @@ -27,36 +27,45 @@ export interface AdminPalette { 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"; +export function deriveAdminPalette( + colors: PublicThemeColors, + overrides?: Partial, +): AdminPalette { 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", + const defaults: AdminPalette = { + canvas: "#0b0f19", + surface: "#151b27", + surfaceElevated: "#1e2635", + text: "#f8fafc", + muted: "#94a3b8", + border: "#2a3448", accent, - accentText: readableColor(accent, [canvas, surface, surfaceElevated]), + accentText: readableColor(accent, ["#0b0f19", "#151b27", "#1e2635"]), accentForeground: readableColor("#ffffff", [accent]), - success: dark ? "#4ade80" : "#15803d", - warning: dark ? "#facc15" : "#a16207", - error: dark ? "#f87171" : "#b91c1c", - info: dark ? "#38bdf8" : "#0369a1", - sidebar: dark ? "#101622" : "#172033", + success: "#22c55e", + warning: "#eab308", + error: "#ef4444", + info: "#3b82f6", + sidebar: "#080c18", sidebarText: "#f8fafc", overlay: "rgba(2, 6, 23, 0.72)", focusRing: accent, }; + if (!overrides) return defaults; + return { + ...defaults, + ...overrides, + accent, + accentText: readableColor(accent, [overrides.canvas ?? defaults.canvas, overrides.surface ?? defaults.surface, overrides.surfaceElevated ?? defaults.surfaceElevated]), + accentForeground: readableColor("#ffffff", [accent]), + success: overrides.success ?? defaults.success, + warning: overrides.warning ?? defaults.warning, + error: overrides.error ?? defaults.error, + info: overrides.info ?? defaults.info, + sidebarText: readableColor(overrides.sidebar ?? defaults.sidebar, [overrides.canvas ?? defaults.canvas, overrides.surface ?? defaults.surface]), + overlay: defaults.overlay, + focusRing: accent, + }; } export function derivePublicForegrounds(c: PublicThemeColors) { diff --git a/src/lib/theme-css.ts b/src/lib/theme-css.ts index 47929ff8..412f81ff 100644 --- a/src/lib/theme-css.ts +++ b/src/lib/theme-css.ts @@ -1,4 +1,4 @@ -import { deriveAdminPalette, derivePublicForegrounds, readableColor } from "@/lib/theme-contrast"; +import { deriveAdminPalette, derivePublicForegrounds, readableColor, type AdminPalette } from "@/lib/theme-contrast"; import type { ThemePalette } from "@/lib/theme-presets"; const CSS_VARIABLES: Record = { @@ -30,9 +30,41 @@ const CSS_VARIABLES: Record = { gradient_to: "gradient-to", }; -export function themePaletteCss(selector: string, palette: ThemePalette): string { +export function adminPaletteCss(admin: AdminPalette): string { + const parts: string[] = [ + `--admin-sidebar-background:${admin.sidebar};`, + `--admin-sidebar-text-readable:${admin.sidebarText};`, + `--admin-sidebar-muted-readable:color-mix(in srgb, ${admin.sidebarText}, ${admin.sidebar} 38%);`, + `--admin-input-text-readable:${readableColor(admin.text, [admin.canvas, admin.surface])};`, + `--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) => [ + `--admin-${status}-foreground:${readableColor("#ffffff", [admin[status]])};`, + `--admin-${status}-subtle:color-mix(in srgb, ${admin[status]} 14%, ${admin.surface});`, + `--admin-${status}-border:color-mix(in srgb, ${admin[status]} 38%, transparent);`, + ]), + ]; + return parts.join(""); +} + +export function themePaletteCss(selector: string, palette: ThemePalette, adminOverrides?: Partial): string { const semantic = derivePublicForegrounds(palette); - const admin = deriveAdminPalette(palette); + const admin = deriveAdminPalette(palette, adminOverrides); const declarations = Object.entries(CSS_VARIABLES).map( ([key, cssName]) => `--${cssName}:${palette[key as keyof ThemePalette]};`, ); @@ -57,32 +89,6 @@ export function themePaletteCss(selector: string, palette: ThemePalette): string `--color-overlay-text-readable:${semantic.byName.overlay};`, `--color-text-subtle-readable:${semantic.byName.subtle};`, `--color-text-disabled-readable:${semantic.byName.disabled};`, - `--admin-sidebar-background:${admin.sidebar};`, - `--admin-sidebar-text-readable:${admin.sidebarText};`, - `--admin-sidebar-muted-readable:color-mix(in srgb, ${admin.sidebarText}, ${admin.sidebar} 38%);`, - `--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) => [ - `--admin-${status}-foreground:${readableColor("#ffffff", [admin[status]])};`, - `--admin-${status}-subtle:color-mix(in srgb, ${admin[status]} 14%, ${admin.surface});`, - `--admin-${status}-border:color-mix(in srgb, ${admin[status]} 38%, transparent);`, - ]), ...(["success", "warning", "error", "info"] as const).flatMap((status) => [ `--color-${status}-text-readable:${semantic.byName[`${status}-text`]};`, `--color-${status}-foreground-readable:${semantic.byName[`${status}-solid`]};`, @@ -90,5 +96,8 @@ export function themePaletteCss(selector: string, palette: ThemePalette): string `--color-${status}-border:color-mix(in srgb, ${palette[`color_${status}`]} 38%, transparent);`, ]), ); + if (adminOverrides || selector !== ":root") { + declarations.push(adminPaletteCss(admin)); + } return `${selector}{${declarations.join("")}}`; }