/ web / src / themes / context.tsx
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  }