fix: add nous-research/ui package

This commit is contained in:
Austin Pickett 2026-04-19 10:48:56 -04:00
parent 957ca79e8e
commit 923539a46b
26 changed files with 798 additions and 637 deletions

View file

@ -3,167 +3,122 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import type { DashboardTheme, ThemeColors, ThemeOverlay } from "./types";
import { BUILTIN_THEMES, defaultTheme } from "./presets";
import type { DashboardTheme, ThemeLayer, ThemePalette } from "./types";
import { api } from "@/lib/api";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** 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";
/** Apply a theme's color overrides to `document.documentElement`. */
function applyColors(colors: ThemeColors) {
/** Turn a ThemeLayer into the two CSS expressions the DS consumes:
* `--<name>` (color-mix'd with alpha) and `--<name>-base` (opaque hex). */
function layerVars(name: "background" | "midground" | "foreground", layer: ThemeLayer) {
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),
};
}
/** Write a theme's palette to `document.documentElement` as inline styles.
* Inline styles beat the `:root { }` rule in index.css, so this cascades
* into every shadcn-compat token defined over the DS triplet. */
function applyPalette(palette: ThemePalette) {
const root = document.documentElement;
for (const [key, value] of Object.entries(colors)) {
root.style.setProperty(`--color-${key}`, value);
const vars = {
...layerVars("background", palette.background),
...layerVars("midground", palette.midground),
...layerVars("foreground", palette.foreground),
"--warm-glow": palette.warmGlow,
"--noise-opacity-mul": String(palette.noiseOpacity),
};
for (const [k, v] of Object.entries(vars)) {
root.style.setProperty(k, v);
}
}
/** Apply overlay overrides (noise + warm-glow). */
function applyOverlay(overlay: ThemeOverlay | undefined) {
const noiseEl = document.querySelector<HTMLElement>(".noise-overlay");
const glowEl = document.querySelector<HTMLElement>(".warm-glow");
if (noiseEl) {
noiseEl.style.opacity = String(overlay?.noiseOpacity ?? 0.10);
noiseEl.style.mixBlendMode = overlay?.noiseBlendMode ?? "color-dodge";
}
if (glowEl) {
glowEl.style.opacity = String(overlay?.warmGlowOpacity ?? 0.22);
if (overlay?.warmGlowColor) {
glowEl.style.background = `radial-gradient(ellipse at 0% 0%, ${overlay.warmGlowColor} 0%, rgba(0,0,0,0) 60%)`;
}
}
}
/** Remove all inline overrides — reverts to stylesheet defaults. */
function clearOverrides() {
const root = document.documentElement;
// Clear color overrides
for (const key of Object.keys(defaultTheme.colors)) {
root.style.removeProperty(`--color-${key}`);
}
// Clear overlay overrides
const noiseEl = document.querySelector<HTMLElement>(".noise-overlay");
const glowEl = document.querySelector<HTMLElement>(".warm-glow");
if (noiseEl) {
noiseEl.style.opacity = "";
noiseEl.style.mixBlendMode = "";
}
if (glowEl) {
glowEl.style.opacity = "";
glowEl.style.background = "";
}
}
function applyTheme(theme: DashboardTheme) {
if (theme.name === "default") {
clearOverrides();
} else {
applyColors(theme.colors);
applyOverlay(theme.overlay);
}
}
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
interface ThemeContextValue {
/** Currently active theme name. */
themeName: string;
/** Currently active theme object. */
theme: DashboardTheme;
/** Available theme names (built-in + any server-provided custom themes). */
availableThemes: Array<{ name: string; label: string; description: string }>;
/** Switch theme — applies CSS immediately and persists to config.yaml. */
setTheme: (name: string) => void;
/** True while initial theme is loading from server. */
loading: boolean;
}
const ThemeContext = createContext<ThemeContextValue>({
themeName: "default",
theme: defaultTheme,
availableThemes: Object.values(BUILTIN_THEMES).map((t) => ({
name: t.name,
label: t.label,
description: t.description,
})),
setTheme: () => {},
loading: true,
});
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export function ThemeProvider({ children }: { children: ReactNode }) {
const [themeName, setThemeName] = useState("default");
const [availableThemes, setAvailableThemes] = useState(
const [themeName, setThemeName] = useState<string>(() => {
if (typeof window === "undefined") return "default";
return window.localStorage.getItem(STORAGE_KEY) ?? "default";
});
const [availableThemes, setAvailableThemes] = useState<
Array<{ description: string; label: string; name: string }>
>(() =>
Object.values(BUILTIN_THEMES).map((t) => ({
name: t.name,
label: t.label,
description: t.description,
})),
);
const [loading, setLoading] = useState(true);
// Fetch active theme + available list from server on mount.
useEffect(() => {
const t = BUILTIN_THEMES[themeName] ?? defaultTheme;
applyPalette(t.palette);
}, [themeName]);
useEffect(() => {
let cancelled = false;
api
.getThemes()
.then((resp) => {
if (resp.themes?.length) {
setAvailableThemes(resp.themes);
}
if (resp.active && resp.active !== "default") {
if (cancelled) return;
if (resp.themes?.length) setAvailableThemes(resp.themes);
if (resp.active && resp.active !== themeName) {
setThemeName(resp.active);
const t = BUILTIN_THEMES[resp.active];
if (t) applyTheme(t);
window.localStorage.setItem(STORAGE_KEY, resp.active);
}
})
.catch(() => {
// Server might not support theme API yet — stay on default.
})
.finally(() => setLoading(false));
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
const resolvedTheme = BUILTIN_THEMES[themeName] ?? defaultTheme;
const setTheme = useCallback((name: string) => {
const next = BUILTIN_THEMES[name] ? name : "default";
setThemeName(next);
window.localStorage.setItem(STORAGE_KEY, next);
api.setTheme(next).catch(() => {});
}, []);
const setTheme = useCallback(
(name: string) => {
const t = BUILTIN_THEMES[name] ?? defaultTheme;
setThemeName(t.name);
applyTheme(t);
// Persist to config.yaml — fire and forget.
api.setTheme(t.name).catch(() => {});
},
[],
const value = useMemo<ThemeContextValue>(
() => ({
theme: BUILTIN_THEMES[themeName] ?? defaultTheme,
themeName,
availableThemes,
setTheme,
}),
[themeName, availableThemes, setTheme],
);
return (
<ThemeContext.Provider
value={{
themeName,
theme: resolvedTheme,
availableThemes,
setTheme,
loading,
}}
>
{children}
</ThemeContext.Provider>
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useTheme() {
export function useTheme(): ThemeContextValue {
return useContext(ThemeContext);
}
const ThemeContext = createContext<ThemeContextValue>({
theme: defaultTheme,
themeName: "default",
availableThemes: Object.values(BUILTIN_THEMES).map((t) => ({
name: t.name,
label: t.label,
description: t.description,
})),
setTheme: () => {},
});
interface ThemeContextValue {
availableThemes: Array<{ description: string; label: string; name: string }>;
setTheme: (name: string) => void;
theme: DashboardTheme;
themeName: string;
}