context.tsx
1 import { 2 createContext, 3 useCallback, 4 useContext, 5 useEffect, 6 useMemo, 7 useState, 8 type ReactNode, 9 } from "react"; 10 import { BUILTIN_THEMES, defaultTheme } from "./presets"; 11 import type { 12 DashboardTheme, 13 ThemeAssets, 14 ThemeColorOverrides, 15 ThemeComponentStyles, 16 ThemeDensity, 17 ThemeLayer, 18 ThemeLayout, 19 ThemeLayoutVariant, 20 ThemePalette, 21 ThemeTypography, 22 } from "./types"; 23 import { api } from "@/lib/api"; 24 25 /** LocalStorage key — pre-applied before the React tree mounts to avoid 26 * a visible flash of the default palette on theme-overridden installs. */ 27 const STORAGE_KEY = "hermes-dashboard-theme"; 28 29 /** Tracks fontUrls we've already injected so multiple theme switches don't 30 * pile up <link> tags. Keyed by URL. */ 31 const INJECTED_FONT_URLS = new Set<string>(); 32 33 // --------------------------------------------------------------------------- 34 // CSS variable builders 35 // --------------------------------------------------------------------------- 36 37 /** Turn a ThemeLayer into the two CSS expressions the DS consumes: 38 * `--<name>` (color-mix'd with alpha) and `--<name>-base` (opaque hex). */ 39 function layerVars( 40 name: "background" | "midground" | "foreground", 41 layer: ThemeLayer, 42 ): Record<string, string> { 43 const pct = Math.round(layer.alpha * 100); 44 return { 45 [`--${name}`]: `color-mix(in srgb, ${layer.hex} ${pct}%, transparent)`, 46 [`--${name}-base`]: layer.hex, 47 [`--${name}-alpha`]: String(layer.alpha), 48 }; 49 } 50 51 function paletteVars(palette: ThemePalette): Record<string, string> { 52 return { 53 ...layerVars("background", palette.background), 54 ...layerVars("midground", palette.midground), 55 ...layerVars("foreground", palette.foreground), 56 "--warm-glow": palette.warmGlow, 57 "--noise-opacity-mul": String(palette.noiseOpacity), 58 }; 59 } 60 61 const DENSITY_MULTIPLIERS: Record<ThemeDensity, string> = { 62 compact: "0.85", 63 comfortable: "1", 64 spacious: "1.2", 65 }; 66 67 function typographyVars(typo: ThemeTypography): Record<string, string> { 68 return { 69 "--theme-font-sans": typo.fontSans, 70 "--theme-font-mono": typo.fontMono, 71 "--theme-font-display": typo.fontDisplay ?? typo.fontSans, 72 "--theme-base-size": typo.baseSize, 73 "--theme-line-height": typo.lineHeight, 74 "--theme-letter-spacing": typo.letterSpacing, 75 }; 76 } 77 78 function layoutVars(layout: ThemeLayout): Record<string, string> { 79 return { 80 "--radius": layout.radius, 81 "--theme-radius": layout.radius, 82 "--theme-spacing-mul": DENSITY_MULTIPLIERS[layout.density] ?? "1", 83 "--theme-density": layout.density, 84 }; 85 } 86 87 /** Map a color-overrides key (camelCase) to its `--color-*` CSS var. */ 88 const OVERRIDE_KEY_TO_VAR: Record<keyof ThemeColorOverrides, string> = { 89 card: "--color-card", 90 cardForeground: "--color-card-foreground", 91 popover: "--color-popover", 92 popoverForeground: "--color-popover-foreground", 93 primary: "--color-primary", 94 primaryForeground: "--color-primary-foreground", 95 secondary: "--color-secondary", 96 secondaryForeground: "--color-secondary-foreground", 97 muted: "--color-muted", 98 mutedForeground: "--color-muted-foreground", 99 accent: "--color-accent", 100 accentForeground: "--color-accent-foreground", 101 destructive: "--color-destructive", 102 destructiveForeground: "--color-destructive-foreground", 103 success: "--color-success", 104 warning: "--color-warning", 105 border: "--color-border", 106 input: "--color-input", 107 ring: "--color-ring", 108 }; 109 110 /** Keys we might have written on a previous theme — needed to know which 111 * properties to clear when a theme with fewer overrides replaces one 112 * with more. */ 113 const ALL_OVERRIDE_VARS = Object.values(OVERRIDE_KEY_TO_VAR); 114 115 function overrideVars( 116 overrides: ThemeColorOverrides | undefined, 117 ): Record<string, string> { 118 if (!overrides) return {}; 119 const out: Record<string, string> = {}; 120 for (const [key, value] of Object.entries(overrides)) { 121 if (!value) continue; 122 const cssVar = OVERRIDE_KEY_TO_VAR[key as keyof ThemeColorOverrides]; 123 if (cssVar) out[cssVar] = value; 124 } 125 return out; 126 } 127 128 // --------------------------------------------------------------------------- 129 // Asset + component-style + layout variant vars 130 // --------------------------------------------------------------------------- 131 132 /** Well-known named asset slots a theme may populate. Kept in sync with 133 * `_THEME_NAMED_ASSET_KEYS` in `hermes_cli/web_server.py`. */ 134 const NAMED_ASSET_KEYS = ["bg", "hero", "logo", "crest", "sidebar", "header"] as const; 135 136 /** Component buckets mirrored from the backend's `_THEME_COMPONENT_BUCKETS`. 137 * Each bucket emits `--component-<bucket>-<kebab-prop>` CSS vars. */ 138 const COMPONENT_BUCKETS = [ 139 "card", "header", "footer", "sidebar", "tab", 140 "progress", "badge", "backdrop", "page", 141 ] as const; 142 143 /** Camel → kebab (`clipPath` → `clip-path`). */ 144 function toKebab(s: string): string { 145 return s.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); 146 } 147 148 /** Build `--theme-asset-*` CSS vars from the assets block. Values are wrapped 149 * in `url(...)` when they look like a bare path/URL; raw CSS expressions 150 * (`linear-gradient(...)`, pre-wrapped `url(...)`, `none`) pass through. */ 151 function assetVars(assets: ThemeAssets | undefined): Record<string, string> { 152 if (!assets) return {}; 153 const out: Record<string, string> = {}; 154 const wrap = (v: string): string => { 155 const trimmed = v.trim(); 156 if (!trimmed) return ""; 157 // Already a CSS image/gradient/url/none — don't re-wrap. 158 if (/^(url\(|linear-gradient|radial-gradient|conic-gradient|none$)/i.test(trimmed)) { 159 return trimmed; 160 } 161 // Bare path / http(s) URL / data: URL → wrap in url(). 162 return `url("${trimmed.replace(/"/g, '\\"')}")`; 163 }; 164 for (const key of NAMED_ASSET_KEYS) { 165 const val = assets[key]; 166 if (typeof val === "string" && val.trim()) { 167 out[`--theme-asset-${key}`] = wrap(val); 168 out[`--theme-asset-${key}-raw`] = val; 169 } 170 } 171 if (assets.custom) { 172 for (const [key, val] of Object.entries(assets.custom)) { 173 if (typeof val !== "string" || !val.trim()) continue; 174 if (!/^[a-zA-Z0-9_-]+$/.test(key)) continue; 175 out[`--theme-asset-custom-${key}`] = wrap(val); 176 out[`--theme-asset-custom-${key}-raw`] = val; 177 } 178 } 179 return out; 180 } 181 182 /** Build `--component-<bucket>-<prop>` CSS vars from the componentStyles 183 * block. Values pass through untouched so themes can use any CSS expression. */ 184 function componentStyleVars( 185 styles: ThemeComponentStyles | undefined, 186 ): Record<string, string> { 187 if (!styles) return {}; 188 const out: Record<string, string> = {}; 189 for (const bucket of COMPONENT_BUCKETS) { 190 const props = (styles as Record<string, Record<string, string> | undefined>)[bucket]; 191 if (!props) continue; 192 for (const [prop, value] of Object.entries(props)) { 193 if (typeof value !== "string" || !value.trim()) continue; 194 // Same guardrail as backend — camelCase or kebab-case alnum only. 195 if (!/^[a-zA-Z0-9_-]+$/.test(prop)) continue; 196 out[`--component-${bucket}-${toKebab(prop)}`] = value; 197 } 198 } 199 return out; 200 } 201 202 // Tracks keys we set on the previous theme so we can clear them when the 203 // next theme has fewer assets / component vars. Without this, switching 204 // from a richly-decorated theme to a plain one would leave stale vars. 205 let _PREV_DYNAMIC_VAR_KEYS: Set<string> = new Set(); 206 207 /** ID for the injected <style> tag that carries a theme's customCSS. 208 * A single tag is reused + replaced on every theme switch. */ 209 const CUSTOM_CSS_STYLE_ID = "hermes-theme-custom-css"; 210 211 function applyCustomCSS(css: string | undefined) { 212 if (typeof document === "undefined") return; 213 let el = document.getElementById(CUSTOM_CSS_STYLE_ID) as HTMLStyleElement | null; 214 if (!css || !css.trim()) { 215 if (el) el.remove(); 216 return; 217 } 218 if (!el) { 219 el = document.createElement("style"); 220 el.id = CUSTOM_CSS_STYLE_ID; 221 el.setAttribute("data-hermes-theme-css", "true"); 222 document.head.appendChild(el); 223 } 224 el.textContent = css; 225 } 226 227 function applyLayoutVariant(variant: ThemeLayoutVariant | undefined) { 228 if (typeof document === "undefined") return; 229 const root = document.documentElement; 230 const final: ThemeLayoutVariant = variant ?? "standard"; 231 root.dataset.layoutVariant = final; 232 root.style.setProperty("--theme-layout-variant", final); 233 } 234 235 // --------------------------------------------------------------------------- 236 // Font stylesheet injection 237 // --------------------------------------------------------------------------- 238 239 function injectFontStylesheet(url: string | undefined) { 240 if (!url || typeof document === "undefined") return; 241 if (INJECTED_FONT_URLS.has(url)) return; 242 // Also skip if the page already has this href (e.g. SSR'd or persisted). 243 const existing = document.querySelector<HTMLLinkElement>( 244 `link[rel="stylesheet"][href="${CSS.escape(url)}"]`, 245 ); 246 if (existing) { 247 INJECTED_FONT_URLS.add(url); 248 return; 249 } 250 const link = document.createElement("link"); 251 link.rel = "stylesheet"; 252 link.href = url; 253 link.setAttribute("data-hermes-theme-font", "true"); 254 document.head.appendChild(link); 255 INJECTED_FONT_URLS.add(url); 256 } 257 258 // --------------------------------------------------------------------------- 259 // Apply a full theme to :root 260 // --------------------------------------------------------------------------- 261 262 function applyTheme(theme: DashboardTheme) { 263 if (typeof document === "undefined") return; 264 const root = document.documentElement; 265 266 // Clear any overrides from a previous theme before applying the new set. 267 for (const cssVar of ALL_OVERRIDE_VARS) { 268 root.style.removeProperty(cssVar); 269 } 270 // Clear dynamic (asset/component) vars from the previous theme so the 271 // new one starts clean — otherwise stale notched clip-paths, hero URLs, 272 // etc. would bleed across theme switches. 273 for (const prevKey of _PREV_DYNAMIC_VAR_KEYS) { 274 root.style.removeProperty(prevKey); 275 } 276 277 const assetMap = assetVars(theme.assets); 278 const componentMap = componentStyleVars(theme.componentStyles); 279 _PREV_DYNAMIC_VAR_KEYS = new Set([ 280 ...Object.keys(assetMap), 281 ...Object.keys(componentMap), 282 ]); 283 284 const vars = { 285 ...paletteVars(theme.palette), 286 ...typographyVars(theme.typography), 287 ...layoutVars(theme.layout), 288 ...overrideVars(theme.colorOverrides), 289 ...assetMap, 290 ...componentMap, 291 }; 292 for (const [k, v] of Object.entries(vars)) { 293 root.style.setProperty(k, v); 294 } 295 296 injectFontStylesheet(theme.typography.fontUrl); 297 applyCustomCSS(theme.customCSS); 298 applyLayoutVariant(theme.layoutVariant); 299 } 300 301 // --------------------------------------------------------------------------- 302 // Provider 303 // --------------------------------------------------------------------------- 304 305 export function ThemeProvider({ children }: { children: ReactNode }) { 306 /** Name of the currently active theme (built-in id or user YAML name). */ 307 const [themeName, setThemeName] = useState<string>(() => { 308 if (typeof window === "undefined") return "default"; 309 return window.localStorage.getItem(STORAGE_KEY) ?? "default"; 310 }); 311 312 /** All selectable themes (shown in the picker). Starts with just the 313 * built-ins; the API call below merges in user themes. */ 314 const [availableThemes, setAvailableThemes] = useState<ThemeSummary[]>(() => 315 Object.values(BUILTIN_THEMES).map((t) => ({ 316 name: t.name, 317 label: t.label, 318 description: t.description, 319 })), 320 ); 321 322 /** Full definitions for user themes keyed by name — the API provides 323 * these so custom YAMLs apply without a client-side stub. */ 324 const [userThemeDefs, setUserThemeDefs] = useState< 325 Record<string, DashboardTheme> 326 >({}); 327 328 // Resolve a theme name to a full DashboardTheme, falling back to default 329 // only when neither a built-in nor a user theme is found. 330 const resolveTheme = useCallback( 331 (name: string): DashboardTheme => { 332 return ( 333 BUILTIN_THEMES[name] ?? 334 userThemeDefs[name] ?? 335 defaultTheme 336 ); 337 }, 338 [userThemeDefs], 339 ); 340 341 // Re-apply on every themeName change, or when user themes arrive from 342 // the API (since the active theme might be a user theme whose definition 343 // hadn't loaded yet on first render). 344 useEffect(() => { 345 applyTheme(resolveTheme(themeName)); 346 }, [themeName, resolveTheme]); 347 348 // Load server-side themes (built-ins + user YAMLs) once on mount. 349 useEffect(() => { 350 let cancelled = false; 351 api 352 .getThemes() 353 .then((resp) => { 354 if (cancelled) return; 355 if (resp.themes?.length) { 356 setAvailableThemes( 357 resp.themes.map((t) => ({ 358 name: t.name, 359 label: t.label, 360 description: t.description, 361 definition: t.definition, 362 })), 363 ); 364 // Index any definitions the server shipped (user themes). 365 const defs: Record<string, DashboardTheme> = {}; 366 for (const entry of resp.themes) { 367 if (entry.definition) { 368 defs[entry.name] = entry.definition; 369 } 370 } 371 if (Object.keys(defs).length > 0) setUserThemeDefs(defs); 372 } 373 if (resp.active && resp.active !== themeName) { 374 setThemeName(resp.active); 375 window.localStorage.setItem(STORAGE_KEY, resp.active); 376 } 377 }) 378 .catch(() => {}); 379 return () => { 380 cancelled = true; 381 }; 382 // eslint-disable-next-line react-hooks/exhaustive-deps 383 }, []); 384 385 const setTheme = useCallback( 386 (name: string) => { 387 // Accept any name the server told us exists OR any built-in. 388 const knownNames = new Set<string>([ 389 ...Object.keys(BUILTIN_THEMES), 390 ...availableThemes.map((t) => t.name), 391 ...Object.keys(userThemeDefs), 392 ]); 393 const next = knownNames.has(name) ? name : "default"; 394 setThemeName(next); 395 if (typeof window !== "undefined") { 396 window.localStorage.setItem(STORAGE_KEY, next); 397 } 398 api.setTheme(next).catch(() => {}); 399 }, 400 [availableThemes, userThemeDefs], 401 ); 402 403 const value = useMemo<ThemeContextValue>( 404 () => ({ 405 theme: resolveTheme(themeName), 406 themeName, 407 availableThemes, 408 setTheme, 409 }), 410 [themeName, availableThemes, setTheme, resolveTheme], 411 ); 412 413 return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>; 414 } 415 416 export function useTheme(): ThemeContextValue { 417 return useContext(ThemeContext); 418 } 419 420 const ThemeContext = createContext<ThemeContextValue>({ 421 theme: defaultTheme, 422 themeName: "default", 423 availableThemes: Object.values(BUILTIN_THEMES).map((t) => ({ 424 name: t.name, 425 label: t.label, 426 description: t.description, 427 })), 428 setTheme: () => {}, 429 }); 430 431 interface ThemeContextValue { 432 availableThemes: ThemeSummary[]; 433 setTheme: (name: string) => void; 434 theme: DashboardTheme; 435 themeName: string; 436 } 437 438 interface ThemeSummary { 439 description: string; 440 label: string; 441 name: string; 442 definition?: DashboardTheme; 443 }