import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode, } from "react"; import { BUILTIN_THEMES, defaultTheme } from "./presets"; import type { DashboardTheme, ThemeAssets, ThemeColorOverrides, ThemeComponentStyles, ThemeDensity, ThemeLayer, ThemeLayout, ThemeLayoutVariant, ThemePalette, ThemeTypography, } from "./types"; import { api } from "@/lib/api"; /** LocalStorage key — pre-applied before the React tree mounts to avoid * a visible flash of the default palette on theme-overridden installs. */ const STORAGE_KEY = "hermes-dashboard-theme"; /** Tracks fontUrls we've already injected so multiple theme switches don't * pile up tags. Keyed by URL. */ const INJECTED_FONT_URLS = new Set(); // --------------------------------------------------------------------------- // CSS variable builders // --------------------------------------------------------------------------- /** Turn a ThemeLayer into the two CSS expressions the DS consumes: * `--` (color-mix'd with alpha) and `---base` (opaque hex). */ function layerVars( name: "background" | "midground" | "foreground", layer: ThemeLayer, ): Record { const pct = Math.round(layer.alpha * 100); return { [`--${name}`]: `color-mix(in srgb, ${layer.hex} ${pct}%, transparent)`, [`--${name}-base`]: layer.hex, [`--${name}-alpha`]: String(layer.alpha), }; } function paletteVars(palette: ThemePalette): Record { return { ...layerVars("background", palette.background), ...layerVars("midground", palette.midground), ...layerVars("foreground", palette.foreground), "--warm-glow": palette.warmGlow, "--noise-opacity-mul": String(palette.noiseOpacity), }; } const DENSITY_MULTIPLIERS: Record = { compact: "0.85", comfortable: "1", spacious: "1.2", }; function typographyVars(typo: ThemeTypography): Record { return { "--theme-font-sans": typo.fontSans, "--theme-font-mono": typo.fontMono, "--theme-font-display": typo.fontDisplay ?? typo.fontSans, "--theme-base-size": typo.baseSize, "--theme-line-height": typo.lineHeight, "--theme-letter-spacing": typo.letterSpacing, }; } function layoutVars(layout: ThemeLayout): Record { return { "--radius": layout.radius, "--theme-radius": layout.radius, "--theme-spacing-mul": DENSITY_MULTIPLIERS[layout.density] ?? "1", "--theme-density": layout.density, }; } /** Map a color-overrides key (camelCase) to its `--color-*` CSS var. */ const OVERRIDE_KEY_TO_VAR: Record = { card: "--color-card", cardForeground: "--color-card-foreground", popover: "--color-popover", popoverForeground: "--color-popover-foreground", primary: "--color-primary", primaryForeground: "--color-primary-foreground", secondary: "--color-secondary", secondaryForeground: "--color-secondary-foreground", muted: "--color-muted", mutedForeground: "--color-muted-foreground", accent: "--color-accent", accentForeground: "--color-accent-foreground", destructive: "--color-destructive", destructiveForeground: "--color-destructive-foreground", success: "--color-success", warning: "--color-warning", border: "--color-border", input: "--color-input", ring: "--color-ring", }; /** Keys we might have written on a previous theme — needed to know which * properties to clear when a theme with fewer overrides replaces one * with more. */ const ALL_OVERRIDE_VARS = Object.values(OVERRIDE_KEY_TO_VAR); function overrideVars( overrides: ThemeColorOverrides | undefined, ): Record { if (!overrides) return {}; const out: Record = {}; for (const [key, value] of Object.entries(overrides)) { if (!value) continue; const cssVar = OVERRIDE_KEY_TO_VAR[key as keyof ThemeColorOverrides]; if (cssVar) out[cssVar] = value; } return out; } // --------------------------------------------------------------------------- // Asset + component-style + layout variant vars // --------------------------------------------------------------------------- /** Well-known named asset slots a theme may populate. Kept in sync with * `_THEME_NAMED_ASSET_KEYS` in `hermes_cli/web_server.py`. */ const NAMED_ASSET_KEYS = ["bg", "hero", "logo", "crest", "sidebar", "header"] as const; /** Component buckets mirrored from the backend's `_THEME_COMPONENT_BUCKETS`. * Each bucket emits `--component--` CSS vars. */ const COMPONENT_BUCKETS = [ "card", "header", "footer", "sidebar", "tab", "progress", "badge", "backdrop", "page", ] as const; /** Camel → kebab (`clipPath` → `clip-path`). */ function toKebab(s: string): string { return s.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); } /** Build `--theme-asset-*` CSS vars from the assets block. Values are wrapped * in `url(...)` when they look like a bare path/URL; raw CSS expressions * (`linear-gradient(...)`, pre-wrapped `url(...)`, `none`) pass through. */ function assetVars(assets: ThemeAssets | undefined): Record { if (!assets) return {}; const out: Record = {}; const wrap = (v: string): string => { const trimmed = v.trim(); if (!trimmed) return ""; // Already a CSS image/gradient/url/none — don't re-wrap. if (/^(url\(|linear-gradient|radial-gradient|conic-gradient|none$)/i.test(trimmed)) { return trimmed; } // Bare path / http(s) URL / data: URL → wrap in url(). return `url("${trimmed.replace(/"/g, '\\"')}")`; }; for (const key of NAMED_ASSET_KEYS) { const val = assets[key]; if (typeof val === "string" && val.trim()) { out[`--theme-asset-${key}`] = wrap(val); out[`--theme-asset-${key}-raw`] = val; } } if (assets.custom) { for (const [key, val] of Object.entries(assets.custom)) { if (typeof val !== "string" || !val.trim()) continue; if (!/^[a-zA-Z0-9_-]+$/.test(key)) continue; out[`--theme-asset-custom-${key}`] = wrap(val); out[`--theme-asset-custom-${key}-raw`] = val; } } return out; } /** Build `--component--` CSS vars from the componentStyles * block. Values pass through untouched so themes can use any CSS expression. */ function componentStyleVars( styles: ThemeComponentStyles | undefined, ): Record { if (!styles) return {}; const out: Record = {}; for (const bucket of COMPONENT_BUCKETS) { const props = (styles as Record | undefined>)[bucket]; if (!props) continue; for (const [prop, value] of Object.entries(props)) { if (typeof value !== "string" || !value.trim()) continue; // Same guardrail as backend — camelCase or kebab-case alnum only. if (!/^[a-zA-Z0-9_-]+$/.test(prop)) continue; out[`--component-${bucket}-${toKebab(prop)}`] = value; } } return out; } // Tracks keys we set on the previous theme so we can clear them when the // next theme has fewer assets / component vars. Without this, switching // from a richly-decorated theme to a plain one would leave stale vars. let _PREV_DYNAMIC_VAR_KEYS: Set = new Set(); /** ID for the injected