Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,615 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { BUILTIN_THEMES, defaultTheme } from "./presets";
|
||||
import {
|
||||
FONT_CHOICES,
|
||||
THEME_DEFAULT_FONT_ID,
|
||||
getFontChoice,
|
||||
type FontChoice,
|
||||
} from "./fonts";
|
||||
import type {
|
||||
DashboardTheme,
|
||||
ThemeAssets,
|
||||
ThemeColorOverrides,
|
||||
ThemeComponentStyles,
|
||||
ThemeDensity,
|
||||
ThemeLayer,
|
||||
ThemeLayout,
|
||||
ThemeLayoutVariant,
|
||||
ThemeListEntry,
|
||||
ThemePalette,
|
||||
ThemeSeriesColors,
|
||||
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";
|
||||
|
||||
/** LocalStorage key for the font override (independent of theme). Holds a
|
||||
* font id from the catalog in `fonts.ts`, or the `THEME_DEFAULT_FONT_ID`
|
||||
* sentinel / absent = "use the active theme's font". Pre-applied before
|
||||
* the React tree mounts (see `main.tsx`) to avoid a font flash. */
|
||||
const FONT_STORAGE_KEY = "hermes-dashboard-font";
|
||||
|
||||
/** Renames of built-in theme keys we've shipped previously. Without this,
|
||||
* users who saved one of the old names in localStorage (or had it
|
||||
* persisted server-side) would silently fall back to `defaultTheme`
|
||||
* because the lookup in `resolveTheme` no longer finds the stale key.
|
||||
* Keep entries here until enough release cycles have passed that we can
|
||||
* reasonably assume nobody still has the old value persisted. */
|
||||
const THEME_NAME_ALIASES: Record<string, string> = {
|
||||
// Renamed during the LENS_5I port + Nous-blue rebrand.
|
||||
"lens-5i": "nous-blue",
|
||||
};
|
||||
|
||||
function migrateThemeName(name: string): string {
|
||||
return THEME_NAME_ALIASES[name] ?? name;
|
||||
}
|
||||
|
||||
/** Tracks fontUrls we've already injected so multiple theme switches don't
|
||||
* pile up <link> tags. Keyed by URL. */
|
||||
const INJECTED_FONT_URLS = new Set<string>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSS variable builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 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,
|
||||
): Record<string, string> {
|
||||
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<string, string> {
|
||||
return {
|
||||
...layerVars("background", palette.background),
|
||||
...layerVars("midground", palette.midground),
|
||||
...layerVars("foreground", palette.foreground),
|
||||
};
|
||||
}
|
||||
|
||||
const DENSITY_MULTIPLIERS: Record<ThemeDensity, string> = {
|
||||
compact: "0.85",
|
||||
comfortable: "1",
|
||||
spacious: "1.2",
|
||||
};
|
||||
|
||||
function typographyVars(typo: ThemeTypography): Record<string, string> {
|
||||
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<string, string> {
|
||||
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<keyof ThemeColorOverrides, string> = {
|
||||
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<string, string> {
|
||||
if (!overrides) return {};
|
||||
const out: Record<string, string> = {};
|
||||
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;
|
||||
}
|
||||
|
||||
/** Map data-series accents to their CSS vars. Themes omit either field to
|
||||
* inherit the `:root` default from `index.css`; when omitted we also
|
||||
* proactively clear any leftover value from a previous theme so switches
|
||||
* don't carry stale colors. */
|
||||
const SERIES_KEY_TO_VAR: Record<keyof ThemeSeriesColors, string> = {
|
||||
inputTokenAccent: "--series-input-token",
|
||||
outputTokenAccent: "--series-output-token",
|
||||
};
|
||||
|
||||
const ALL_SERIES_VARS = Object.values(SERIES_KEY_TO_VAR);
|
||||
|
||||
function seriesColorVars(
|
||||
series: ThemeSeriesColors | undefined,
|
||||
): Record<string, string> {
|
||||
if (!series) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(series)) {
|
||||
if (!value) continue;
|
||||
const cssVar = SERIES_KEY_TO_VAR[key as keyof ThemeSeriesColors];
|
||||
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-<bucket>-<kebab-prop>` 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<string, string> {
|
||||
if (!assets) return {};
|
||||
const out: Record<string, string> = {};
|
||||
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-<bucket>-<prop>` CSS vars from the componentStyles
|
||||
* block. Values pass through untouched so themes can use any CSS expression. */
|
||||
function componentStyleVars(
|
||||
styles: ThemeComponentStyles | undefined,
|
||||
): Record<string, string> {
|
||||
if (!styles) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const bucket of COMPONENT_BUCKETS) {
|
||||
const props = (styles as Record<string, Record<string, string> | 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<string> = new Set();
|
||||
|
||||
/** ID for the injected <style> tag that carries a theme's customCSS.
|
||||
* A single tag is reused + replaced on every theme switch. */
|
||||
const CUSTOM_CSS_STYLE_ID = "hermes-theme-custom-css";
|
||||
|
||||
function applyCustomCSS(css: string | undefined) {
|
||||
if (typeof document === "undefined") return;
|
||||
let el = document.getElementById(CUSTOM_CSS_STYLE_ID) as HTMLStyleElement | null;
|
||||
if (!css || !css.trim()) {
|
||||
if (el) el.remove();
|
||||
return;
|
||||
}
|
||||
if (!el) {
|
||||
el = document.createElement("style");
|
||||
el.id = CUSTOM_CSS_STYLE_ID;
|
||||
el.setAttribute("data-hermes-theme-css", "true");
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.textContent = css;
|
||||
}
|
||||
|
||||
function applyLayoutVariant(variant: ThemeLayoutVariant | undefined) {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
const final: ThemeLayoutVariant = variant ?? "standard";
|
||||
root.dataset.layoutVariant = final;
|
||||
root.style.setProperty("--theme-layout-variant", final);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Font stylesheet injection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function injectFontStylesheet(url: string | undefined) {
|
||||
if (!url || typeof document === "undefined") return;
|
||||
if (INJECTED_FONT_URLS.has(url)) return;
|
||||
// Also skip if the page already has this href (e.g. SSR'd or persisted).
|
||||
const existing = document.querySelector<HTMLLinkElement>(
|
||||
`link[rel="stylesheet"][href="${CSS.escape(url)}"]`,
|
||||
);
|
||||
if (existing) {
|
||||
INJECTED_FONT_URLS.add(url);
|
||||
return;
|
||||
}
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = url;
|
||||
link.setAttribute("data-hermes-theme-font", "true");
|
||||
document.head.appendChild(link);
|
||||
INJECTED_FONT_URLS.add(url);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Font override (independent of theme)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The active font-override id, mirrored at module scope so `applyTheme`
|
||||
* can re-assert it after every theme switch (theme application rewrites
|
||||
* `--theme-font-sans`, so the override has to win again afterwards). */
|
||||
let _ACTIVE_FONT_OVERRIDE: string = THEME_DEFAULT_FONT_ID;
|
||||
|
||||
/** Apply (or clear) the font override on `:root`. When a catalog font is
|
||||
* active we override `--theme-font-sans` and `--theme-font-display` and
|
||||
* inject its webfont; the theme keeps ownership of `--theme-font-mono`
|
||||
* (code/terminal) so picking a body font doesn't mangle code blocks.
|
||||
* Passing the theme-default sentinel removes the override so the theme's
|
||||
* own font shows through. */
|
||||
function applyFontOverride(fontId: string | undefined) {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
const choice: FontChoice | undefined = getFontChoice(fontId);
|
||||
if (!choice) {
|
||||
// Clear → fall back to whatever the active theme set (applyTheme already
|
||||
// wrote the theme's --theme-font-sans/-display before this runs).
|
||||
root.style.removeProperty("--theme-font-override-sans");
|
||||
return;
|
||||
}
|
||||
injectFontStylesheet(choice.fontUrl);
|
||||
// Set both the override marker var (used by the picker for diagnostics)
|
||||
// and the live consumed vars. We re-set the consumed vars directly so the
|
||||
// change is immediate and survives the next applyTheme via _ACTIVE_FONT_OVERRIDE.
|
||||
root.style.setProperty("--theme-font-override-sans", choice.stack);
|
||||
root.style.setProperty("--theme-font-sans", choice.stack);
|
||||
root.style.setProperty("--theme-font-display", choice.stack);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Apply a full theme to :root
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function applyTheme(theme: DashboardTheme) {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
|
||||
// Clear any overrides from a previous theme before applying the new set.
|
||||
for (const cssVar of ALL_OVERRIDE_VARS) {
|
||||
root.style.removeProperty(cssVar);
|
||||
}
|
||||
// Same clear-then-set for series colors so a theme that defines them
|
||||
// (e.g. Nous Blue) doesn't leave its values behind when the user
|
||||
// switches to a theme that inherits the `:root` defaults.
|
||||
for (const cssVar of ALL_SERIES_VARS) {
|
||||
root.style.removeProperty(cssVar);
|
||||
}
|
||||
// Clear dynamic (asset/component) vars from the previous theme so the
|
||||
// new one starts clean — otherwise stale notched clip-paths, hero URLs,
|
||||
// etc. would bleed across theme switches.
|
||||
for (const prevKey of _PREV_DYNAMIC_VAR_KEYS) {
|
||||
root.style.removeProperty(prevKey);
|
||||
}
|
||||
|
||||
const assetMap = assetVars(theme.assets);
|
||||
const componentMap = componentStyleVars(theme.componentStyles);
|
||||
_PREV_DYNAMIC_VAR_KEYS = new Set([
|
||||
...Object.keys(assetMap),
|
||||
...Object.keys(componentMap),
|
||||
]);
|
||||
|
||||
const vars = {
|
||||
...paletteVars(theme.palette),
|
||||
...typographyVars(theme.typography),
|
||||
...layoutVars(theme.layout),
|
||||
...overrideVars(theme.colorOverrides),
|
||||
...seriesColorVars(theme.seriesColors),
|
||||
...assetMap,
|
||||
...componentMap,
|
||||
};
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
root.style.setProperty(k, v);
|
||||
}
|
||||
|
||||
injectFontStylesheet(theme.typography.fontUrl);
|
||||
applyCustomCSS(theme.customCSS);
|
||||
applyLayoutVariant(theme.layoutVariant);
|
||||
|
||||
// Terminal colors — read by ChatPage via useTheme(); also available as CSS vars.
|
||||
root.style.setProperty(
|
||||
"--theme-terminal-background",
|
||||
theme.terminalBackground ?? "#000000",
|
||||
);
|
||||
root.style.setProperty(
|
||||
"--theme-terminal-foreground",
|
||||
theme.terminalForeground ?? "#f0e6d2",
|
||||
);
|
||||
|
||||
// Re-assert the font override last: theme application just rewrote
|
||||
// --theme-font-sans/-display, so an active override has to win again.
|
||||
applyFontOverride(_ACTIVE_FONT_OVERRIDE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
/** Name of the currently active theme (built-in id or user YAML name). */
|
||||
const [themeName, setThemeName] = useState<string>(() => {
|
||||
if (typeof window === "undefined") return "default";
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY) ?? "default";
|
||||
const migrated = migrateThemeName(stored);
|
||||
// Write the migrated name back so future reads converge on the new
|
||||
// key and we eventually retire the alias entry.
|
||||
if (migrated !== stored) {
|
||||
window.localStorage.setItem(STORAGE_KEY, migrated);
|
||||
}
|
||||
return migrated;
|
||||
});
|
||||
|
||||
/** All selectable themes (shown in the picker). Starts with just the
|
||||
* built-ins; the API call below merges in user themes. */
|
||||
const [availableThemes, setAvailableThemes] = useState<ThemeListEntry[]>(() =>
|
||||
Object.values(BUILTIN_THEMES).map((t) => ({
|
||||
name: t.name,
|
||||
label: t.label,
|
||||
description: t.description,
|
||||
})),
|
||||
);
|
||||
|
||||
/** Full definitions for user themes keyed by name — the API provides
|
||||
* these so custom YAMLs apply without a client-side stub. */
|
||||
const [userThemeDefs, setUserThemeDefs] = useState<
|
||||
Record<string, DashboardTheme>
|
||||
>({});
|
||||
|
||||
/** Active font-override id (independent of theme). `THEME_DEFAULT_FONT_ID`
|
||||
* = no override. Seeded from localStorage so it's applied flash-free. */
|
||||
const [fontId, setFontId] = useState<string>(() => {
|
||||
if (typeof window === "undefined") return THEME_DEFAULT_FONT_ID;
|
||||
const stored = window.localStorage.getItem(FONT_STORAGE_KEY);
|
||||
const valid = stored && getFontChoice(stored) ? stored : THEME_DEFAULT_FONT_ID;
|
||||
_ACTIVE_FONT_OVERRIDE = valid;
|
||||
return valid;
|
||||
});
|
||||
|
||||
// Resolve a theme name to a full DashboardTheme, falling back to default
|
||||
// only when neither a built-in nor a user theme is found.
|
||||
const resolveTheme = useCallback(
|
||||
(name: string): DashboardTheme => {
|
||||
return (
|
||||
BUILTIN_THEMES[name] ??
|
||||
userThemeDefs[name] ??
|
||||
defaultTheme
|
||||
);
|
||||
},
|
||||
[userThemeDefs],
|
||||
);
|
||||
|
||||
// Apply the active theme (and re-assert the font override at its tail)
|
||||
// whenever the theme, the resolver, OR the font override changes. Folding
|
||||
// font into the same effect means clearing the override re-runs applyTheme,
|
||||
// which restores the theme's own font; setting it re-asserts the override.
|
||||
useEffect(() => {
|
||||
_ACTIVE_FONT_OVERRIDE = fontId;
|
||||
applyTheme(resolveTheme(themeName));
|
||||
}, [themeName, resolveTheme, fontId]);
|
||||
|
||||
// Load server-side themes (built-ins + user YAMLs) once on mount.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.getThemes()
|
||||
.then((resp) => {
|
||||
if (cancelled) return;
|
||||
if (resp.themes?.length) {
|
||||
setAvailableThemes(
|
||||
resp.themes.map((t) => ({
|
||||
name: t.name,
|
||||
label: t.label,
|
||||
description: t.description,
|
||||
definition: t.definition,
|
||||
})),
|
||||
);
|
||||
// Index any definitions the server shipped (user themes).
|
||||
const defs: Record<string, DashboardTheme> = {};
|
||||
for (const entry of resp.themes) {
|
||||
if (entry.definition) {
|
||||
defs[entry.name] = entry.definition;
|
||||
}
|
||||
}
|
||||
if (Object.keys(defs).length > 0) setUserThemeDefs(defs);
|
||||
}
|
||||
if (resp.active) {
|
||||
const migratedActive = migrateThemeName(resp.active);
|
||||
if (migratedActive !== themeName) {
|
||||
setThemeName(migratedActive);
|
||||
window.localStorage.setItem(STORAGE_KEY, migratedActive);
|
||||
}
|
||||
// If the server is still persisting the stale key, push the
|
||||
// migrated value back so it converges too — otherwise every
|
||||
// future page load would re-trigger this branch.
|
||||
if (migratedActive !== resp.active) {
|
||||
api.setTheme(migratedActive).catch(() => {});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Load the server-persisted font override once on mount. The server is
|
||||
// the source of truth across browsers; localStorage just avoids the flash.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.getFontPref()
|
||||
.then((resp) => {
|
||||
if (cancelled) return;
|
||||
const serverId =
|
||||
resp?.font && getFontChoice(resp.font) ? resp.font : THEME_DEFAULT_FONT_ID;
|
||||
if (serverId !== fontId) {
|
||||
setFontId(serverId);
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(FONT_STORAGE_KEY, serverId);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const setTheme = useCallback(
|
||||
(name: string) => {
|
||||
// Accept any name the server told us exists OR any built-in.
|
||||
const knownNames = new Set<string>([
|
||||
...Object.keys(BUILTIN_THEMES),
|
||||
...availableThemes.map((t) => t.name),
|
||||
...Object.keys(userThemeDefs),
|
||||
]);
|
||||
const next = knownNames.has(name) ? name : "default";
|
||||
setThemeName(next);
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(STORAGE_KEY, next);
|
||||
}
|
||||
api.setTheme(next).catch(() => {});
|
||||
},
|
||||
[availableThemes, userThemeDefs],
|
||||
);
|
||||
|
||||
const setFont = useCallback((id: string) => {
|
||||
const next = getFontChoice(id) ? id : THEME_DEFAULT_FONT_ID;
|
||||
setFontId(next);
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(FONT_STORAGE_KEY, next);
|
||||
}
|
||||
api.setFontPref(next).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({
|
||||
theme: resolveTheme(themeName),
|
||||
themeName,
|
||||
availableThemes,
|
||||
setTheme,
|
||||
fontId,
|
||||
fontChoices: FONT_CHOICES,
|
||||
setFont,
|
||||
}),
|
||||
[themeName, availableThemes, setTheme, resolveTheme, fontId, setFont],
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
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: () => {},
|
||||
fontId: THEME_DEFAULT_FONT_ID,
|
||||
fontChoices: FONT_CHOICES,
|
||||
setFont: () => {},
|
||||
});
|
||||
|
||||
interface ThemeContextValue {
|
||||
availableThemes: ThemeListEntry[];
|
||||
setTheme: (name: string) => void;
|
||||
theme: DashboardTheme;
|
||||
themeName: string;
|
||||
/** Active font-override id (`THEME_DEFAULT_FONT_ID` = no override). */
|
||||
fontId: string;
|
||||
/** Curated font catalog for the picker. */
|
||||
fontChoices: FontChoice[];
|
||||
/** Set the font override (independent of theme). */
|
||||
setFont: (id: string) => void;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Curated UI-font catalog for the dashboard font override.
|
||||
*
|
||||
* The font override is an independent layer that sits ON TOP of the active
|
||||
* theme: a theme still ships its own `typography.fontSans` default, but a
|
||||
* user can pick any font here and it persists across theme switches. Picking
|
||||
* "Theme default" clears the override and returns to whatever the active
|
||||
* theme specifies.
|
||||
*
|
||||
* Why a curated catalog instead of a free-text font name + URL box: the
|
||||
* `fontUrl` is injected into the page as a `<link rel="stylesheet">`, so
|
||||
* accepting an arbitrary user-supplied URL would be a self-XSS / SSRF-ish
|
||||
* footgun in the dashboard. A vetted catalog keeps the injected origins
|
||||
* fixed (system stacks + Google Fonts) while still giving real choice. The
|
||||
* matching allow-list on the backend (`_FONT_CHOICES` in web_server.py)
|
||||
* rejects any id not defined here.
|
||||
*
|
||||
* Keep `FONT_CHOICES` in sync with `_FONT_CHOICES` in
|
||||
* `hermes_cli/web_server.py` — the ids must match exactly.
|
||||
*/
|
||||
|
||||
/** System stacks reused from presets so "System" choices need no webfont. */
|
||||
const SYSTEM_SANS =
|
||||
'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
|
||||
const SYSTEM_MONO =
|
||||
'ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace';
|
||||
const SYSTEM_SERIF =
|
||||
'Georgia, Cambria, "Times New Roman", Times, serif';
|
||||
|
||||
export type FontCategory = "sans" | "serif" | "mono";
|
||||
|
||||
export interface FontChoice {
|
||||
/** Stable id persisted in config / localStorage. */
|
||||
id: string;
|
||||
/** Human-readable label shown in the picker. */
|
||||
label: string;
|
||||
/** Rough grouping for the picker. */
|
||||
category: FontCategory;
|
||||
/** CSS font-family stack applied to `--theme-font-sans` (+ display). */
|
||||
stack: string;
|
||||
/** Optional Google-Fonts (or other vetted) stylesheet URL. */
|
||||
fontUrl?: string;
|
||||
}
|
||||
|
||||
/** Sentinel id meaning "no override — use the active theme's font". */
|
||||
export const THEME_DEFAULT_FONT_ID = "theme";
|
||||
|
||||
const GF = (family: string): string =>
|
||||
`https://fonts.googleapis.com/css2?family=${family}&display=swap`;
|
||||
|
||||
/**
|
||||
* The curated set. Order is the display order in the picker (grouped by
|
||||
* category in the UI). `stack` always ends in a system fallback so a font
|
||||
* that fails to load still renders something sane.
|
||||
*/
|
||||
export const FONT_CHOICES: FontChoice[] = [
|
||||
// ── System (no webfont fetch) ──────────────────────────────────────────
|
||||
{ id: "system-sans", label: "System Sans", category: "sans", stack: SYSTEM_SANS },
|
||||
{ id: "system-serif", label: "System Serif", category: "serif", stack: SYSTEM_SERIF },
|
||||
{ id: "system-mono", label: "System Mono", category: "mono", stack: SYSTEM_MONO },
|
||||
|
||||
// ── Sans ────────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: "inter",
|
||||
label: "Inter",
|
||||
category: "sans",
|
||||
stack: `"Inter", ${SYSTEM_SANS}`,
|
||||
fontUrl: GF("Inter:wght@400;500;600;700"),
|
||||
},
|
||||
{
|
||||
id: "ibm-plex-sans",
|
||||
label: "IBM Plex Sans",
|
||||
category: "sans",
|
||||
stack: `"IBM Plex Sans", ${SYSTEM_SANS}`,
|
||||
fontUrl: GF("IBM+Plex+Sans:wght@400;500;600;700"),
|
||||
},
|
||||
{
|
||||
id: "work-sans",
|
||||
label: "Work Sans",
|
||||
category: "sans",
|
||||
stack: `"Work Sans", ${SYSTEM_SANS}`,
|
||||
fontUrl: GF("Work+Sans:wght@400;500;600;700"),
|
||||
},
|
||||
{
|
||||
id: "atkinson-hyperlegible",
|
||||
label: "Atkinson Hyperlegible",
|
||||
category: "sans",
|
||||
stack: `"Atkinson Hyperlegible", ${SYSTEM_SANS}`,
|
||||
fontUrl: GF("Atkinson+Hyperlegible:wght@400;700"),
|
||||
},
|
||||
{
|
||||
id: "dm-sans",
|
||||
label: "DM Sans",
|
||||
category: "sans",
|
||||
stack: `"DM Sans", ${SYSTEM_SANS}`,
|
||||
fontUrl: GF("DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600;9..40,700"),
|
||||
},
|
||||
|
||||
// ── Serif ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: "spectral",
|
||||
label: "Spectral",
|
||||
category: "serif",
|
||||
stack: `"Spectral", ${SYSTEM_SERIF}`,
|
||||
fontUrl: GF("Spectral:wght@400;500;600;700"),
|
||||
},
|
||||
{
|
||||
id: "fraunces",
|
||||
label: "Fraunces",
|
||||
category: "serif",
|
||||
stack: `"Fraunces", ${SYSTEM_SERIF}`,
|
||||
fontUrl: GF("Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600"),
|
||||
},
|
||||
{
|
||||
id: "source-serif",
|
||||
label: "Source Serif 4",
|
||||
category: "serif",
|
||||
stack: `"Source Serif 4", ${SYSTEM_SERIF}`,
|
||||
fontUrl: GF("Source+Serif+4:opsz,wght@8..60,400;8..60,500;8..60,600;8..60,700"),
|
||||
},
|
||||
|
||||
// ── Mono ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: "jetbrains-mono",
|
||||
label: "JetBrains Mono",
|
||||
category: "mono",
|
||||
stack: `"JetBrains Mono", ${SYSTEM_MONO}`,
|
||||
fontUrl: GF("JetBrains+Mono:wght@400;500;700"),
|
||||
},
|
||||
{
|
||||
id: "ibm-plex-mono",
|
||||
label: "IBM Plex Mono",
|
||||
category: "mono",
|
||||
stack: `"IBM Plex Mono", ${SYSTEM_MONO}`,
|
||||
fontUrl: GF("IBM+Plex+Mono:wght@400;500;700"),
|
||||
},
|
||||
{
|
||||
id: "space-mono",
|
||||
label: "Space Mono",
|
||||
category: "mono",
|
||||
stack: `"Space Mono", ${SYSTEM_MONO}`,
|
||||
fontUrl: GF("Space+Mono:wght@400;700"),
|
||||
},
|
||||
];
|
||||
|
||||
const FONT_BY_ID: Record<string, FontChoice> = Object.fromEntries(
|
||||
FONT_CHOICES.map((f) => [f.id, f]),
|
||||
);
|
||||
|
||||
/** Look up a font choice by id. Returns undefined for the theme-default
|
||||
* sentinel and for any unknown id. */
|
||||
export function getFontChoice(id: string | null | undefined): FontChoice | undefined {
|
||||
if (!id || id === THEME_DEFAULT_FONT_ID) return undefined;
|
||||
return FONT_BY_ID[id];
|
||||
}
|
||||
|
||||
/** Whether an id refers to a real catalog font (vs. theme-default/unknown). */
|
||||
export function isOverrideFont(id: string | null | undefined): boolean {
|
||||
return getFontChoice(id) !== undefined;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export { ThemeProvider, useTheme } from "./context";
|
||||
export { BUILTIN_THEMES, defaultTheme } from "./presets";
|
||||
export {
|
||||
FONT_CHOICES,
|
||||
THEME_DEFAULT_FONT_ID,
|
||||
getFontChoice,
|
||||
isOverrideFont,
|
||||
} from "./fonts";
|
||||
export type { FontChoice, FontCategory } from "./fonts";
|
||||
export type { DashboardTheme, ThemeLayer, ThemeListEntry, ThemeListResponse, ThemePalette } from "./types";
|
||||
@@ -0,0 +1,240 @@
|
||||
import type { DashboardTheme, ThemeTypography, ThemeLayout } from "./types";
|
||||
|
||||
/**
|
||||
* Built-in dashboard themes.
|
||||
*
|
||||
* Each theme defines its own palette, typography, and layout so switching
|
||||
* themes produces visible changes beyond just color — fonts, density, and
|
||||
* corner-radius all shift to match the theme's personality.
|
||||
*
|
||||
* Theme names must stay in sync with the backend's
|
||||
* `_BUILTIN_DASHBOARD_THEMES` list in `hermes_cli/web_server.py`.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared typography / layout presets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Default system stack — neutral, safe fallback for every platform. */
|
||||
const SYSTEM_SANS =
|
||||
'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
|
||||
const SYSTEM_MONO =
|
||||
'ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace';
|
||||
|
||||
const DEFAULT_TYPOGRAPHY: ThemeTypography = {
|
||||
fontSans: SYSTEM_SANS,
|
||||
fontMono: SYSTEM_MONO,
|
||||
baseSize: "15px",
|
||||
lineHeight: "1.55",
|
||||
letterSpacing: "0",
|
||||
};
|
||||
|
||||
const DEFAULT_LAYOUT: ThemeLayout = {
|
||||
radius: "0.5rem",
|
||||
density: "comfortable",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Themes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const defaultTheme: DashboardTheme = {
|
||||
name: "default",
|
||||
label: "Hermes Teal",
|
||||
description: "Classic dark teal — the canonical Hermes look",
|
||||
palette: {
|
||||
background: { hex: "#041c1c", alpha: 1 },
|
||||
midground: { hex: "#ffe6cb", alpha: 1 },
|
||||
foreground: { hex: "#ffffff", alpha: 0 },
|
||||
warmGlow: "rgba(255, 189, 56, 0.35)",
|
||||
noiseOpacity: 1,
|
||||
},
|
||||
typography: DEFAULT_TYPOGRAPHY,
|
||||
layout: DEFAULT_LAYOUT,
|
||||
terminalBackground: "#000000",
|
||||
};
|
||||
|
||||
export const midnightTheme: DashboardTheme = {
|
||||
name: "midnight",
|
||||
label: "Midnight",
|
||||
description: "Deep blue-violet with cool accents",
|
||||
palette: {
|
||||
background: { hex: "#0a0a1f", alpha: 1 },
|
||||
midground: { hex: "#d4c8ff", alpha: 1 },
|
||||
foreground: { hex: "#ffffff", alpha: 0 },
|
||||
warmGlow: "rgba(167, 139, 250, 0.32)",
|
||||
noiseOpacity: 0.8,
|
||||
},
|
||||
typography: {
|
||||
...DEFAULT_TYPOGRAPHY,
|
||||
fontSans: `"Inter", ${SYSTEM_SANS}`,
|
||||
fontMono: `"JetBrains Mono", ${SYSTEM_MONO}`,
|
||||
fontUrl:
|
||||
"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap",
|
||||
letterSpacing: "-0.005em",
|
||||
},
|
||||
layout: {
|
||||
...DEFAULT_LAYOUT,
|
||||
radius: "0.75rem",
|
||||
},
|
||||
};
|
||||
|
||||
export const emberTheme: DashboardTheme = {
|
||||
name: "ember",
|
||||
label: "Ember",
|
||||
description: "Warm crimson and bronze — forge vibes",
|
||||
palette: {
|
||||
background: { hex: "#1a0a06", alpha: 1 },
|
||||
midground: { hex: "#ffd8b0", alpha: 1 },
|
||||
foreground: { hex: "#ffffff", alpha: 0 },
|
||||
warmGlow: "rgba(249, 115, 22, 0.38)",
|
||||
noiseOpacity: 1,
|
||||
},
|
||||
typography: {
|
||||
...DEFAULT_TYPOGRAPHY,
|
||||
fontSans: `"Spectral", Georgia, "Times New Roman", serif`,
|
||||
fontMono: `"IBM Plex Mono", ${SYSTEM_MONO}`,
|
||||
fontUrl:
|
||||
"https://fonts.googleapis.com/css2?family=Spectral:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;700&display=swap",
|
||||
},
|
||||
layout: {
|
||||
...DEFAULT_LAYOUT,
|
||||
radius: "0.25rem",
|
||||
},
|
||||
colorOverrides: {
|
||||
destructive: "#c92d0f",
|
||||
warning: "#f97316",
|
||||
},
|
||||
};
|
||||
|
||||
export const monoTheme: DashboardTheme = {
|
||||
name: "mono",
|
||||
label: "Mono",
|
||||
description: "Clean grayscale — minimal and focused",
|
||||
palette: {
|
||||
background: { hex: "#0e0e0e", alpha: 1 },
|
||||
midground: { hex: "#eaeaea", alpha: 1 },
|
||||
foreground: { hex: "#ffffff", alpha: 0 },
|
||||
warmGlow: "rgba(255, 255, 255, 0.1)",
|
||||
noiseOpacity: 0.6,
|
||||
},
|
||||
typography: {
|
||||
...DEFAULT_TYPOGRAPHY,
|
||||
fontSans: `"IBM Plex Sans", ${SYSTEM_SANS}`,
|
||||
fontMono: `"IBM Plex Mono", ${SYSTEM_MONO}`,
|
||||
fontUrl:
|
||||
"https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap",
|
||||
},
|
||||
layout: {
|
||||
...DEFAULT_LAYOUT,
|
||||
radius: "0",
|
||||
},
|
||||
};
|
||||
|
||||
export const cyberpunkTheme: DashboardTheme = {
|
||||
name: "cyberpunk",
|
||||
label: "Cyberpunk",
|
||||
description: "Neon green on black — matrix terminal",
|
||||
palette: {
|
||||
background: { hex: "#040608", alpha: 1 },
|
||||
midground: { hex: "#9bffcf", alpha: 1 },
|
||||
foreground: { hex: "#ffffff", alpha: 0 },
|
||||
warmGlow: "rgba(0, 255, 136, 0.22)",
|
||||
noiseOpacity: 1.2,
|
||||
},
|
||||
typography: {
|
||||
...DEFAULT_TYPOGRAPHY,
|
||||
fontSans: `"Share Tech Mono", "JetBrains Mono", ${SYSTEM_MONO}`,
|
||||
fontMono: `"Share Tech Mono", "JetBrains Mono", ${SYSTEM_MONO}`,
|
||||
fontUrl:
|
||||
"https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=JetBrains+Mono:wght@400;700&display=swap",
|
||||
},
|
||||
layout: {
|
||||
...DEFAULT_LAYOUT,
|
||||
radius: "0",
|
||||
},
|
||||
colorOverrides: {
|
||||
success: "#00ff88",
|
||||
warning: "#ffd700",
|
||||
destructive: "#ff0055",
|
||||
},
|
||||
};
|
||||
|
||||
export const roseTheme: DashboardTheme = {
|
||||
name: "rose",
|
||||
label: "Rosé",
|
||||
description: "Soft pink and warm ivory — easy on the eyes",
|
||||
palette: {
|
||||
background: { hex: "#1a0f15", alpha: 1 },
|
||||
midground: { hex: "#ffd4e1", alpha: 1 },
|
||||
foreground: { hex: "#ffffff", alpha: 0 },
|
||||
warmGlow: "rgba(249, 168, 212, 0.3)",
|
||||
noiseOpacity: 0.9,
|
||||
},
|
||||
typography: {
|
||||
...DEFAULT_TYPOGRAPHY,
|
||||
fontSans: `"Fraunces", Georgia, serif`,
|
||||
fontMono: `"DM Mono", ${SYSTEM_MONO}`,
|
||||
fontUrl:
|
||||
"https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=DM+Mono:wght@400;500&display=swap",
|
||||
},
|
||||
layout: {
|
||||
...DEFAULT_LAYOUT,
|
||||
radius: "1rem",
|
||||
},
|
||||
};
|
||||
|
||||
/** Light mode — vivid Nous-blue accents on a cream canvas. */
|
||||
export const nousBlueTheme: DashboardTheme = {
|
||||
name: "nous-blue",
|
||||
label: "Nous Blue",
|
||||
description: "Light mode — vivid Nous-blue accents on cream canvas",
|
||||
palette: {
|
||||
background: { hex: "#E8F2FD", alpha: 1 },
|
||||
midground: { hex: "#0053FD", alpha: 1 },
|
||||
foreground: { hex: "#170d02", alpha: 0 },
|
||||
warmGlow: "rgba(0, 83, 253, 0.12)",
|
||||
noiseOpacity: 0,
|
||||
},
|
||||
typography: DEFAULT_TYPOGRAPHY,
|
||||
layout: DEFAULT_LAYOUT,
|
||||
terminalBackground: "#f5f8fc",
|
||||
terminalForeground: "#170d02",
|
||||
seriesColors: {
|
||||
inputTokenAccent: "#001934",
|
||||
outputTokenAccent: "#0053fd",
|
||||
},
|
||||
swatchColors: ["#170d02", "#0053FD", "#E8F2FD"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Same look as ``defaultTheme`` but with a larger root font size, looser
|
||||
* line-height, and ``spacious`` density so every rem-based size in the
|
||||
* dashboard scales up. For users who find the default 15px UI too dense.
|
||||
*/
|
||||
export const defaultLargeTheme: DashboardTheme = {
|
||||
name: "default-large",
|
||||
label: "Hermes Teal (Large)",
|
||||
description: "Hermes Teal with bigger fonts and roomier spacing",
|
||||
palette: defaultTheme.palette,
|
||||
typography: {
|
||||
...DEFAULT_TYPOGRAPHY,
|
||||
baseSize: "18px",
|
||||
lineHeight: "1.65",
|
||||
},
|
||||
layout: {
|
||||
...DEFAULT_LAYOUT,
|
||||
density: "spacious",
|
||||
},
|
||||
};
|
||||
|
||||
export const BUILTIN_THEMES: Record<string, DashboardTheme> = {
|
||||
default: defaultTheme,
|
||||
"default-large": defaultLargeTheme,
|
||||
"nous-blue": nousBlueTheme,
|
||||
midnight: midnightTheme,
|
||||
ember: emberTheme,
|
||||
mono: monoTheme,
|
||||
cyberpunk: cyberpunkTheme,
|
||||
rose: roseTheme,
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Dashboard theme model.
|
||||
*
|
||||
* Themes customise three orthogonal layers:
|
||||
*
|
||||
* 1. `palette` — the 3-layer color triplet (background/midground/
|
||||
* foreground). Legacy `warmGlow` / `noiseOpacity`
|
||||
* fields remain for theme YAML compat but are unused
|
||||
* by the lightweight shell.
|
||||
* 2. `typography` — font families, base font size, line height,
|
||||
* letter spacing. An optional `fontUrl` is injected
|
||||
* as `<link rel="stylesheet">` so self-hosted and
|
||||
* Google/Bunny/etc-hosted fonts both work.
|
||||
* 3. `layout` — corner radius and density (spacing multiplier).
|
||||
*
|
||||
* Plus an optional `colorOverrides` escape hatch for themes that want to
|
||||
* pin specific shadcn tokens to exact values (e.g. a pastel theme that
|
||||
* needs a softer `destructive` red than the derived default).
|
||||
*/
|
||||
|
||||
/** A color layer: hex base + alpha (0–1). */
|
||||
export interface ThemeLayer {
|
||||
alpha: number;
|
||||
hex: string;
|
||||
}
|
||||
|
||||
export interface ThemePalette {
|
||||
/** Deepest canvas color (typically near-black). */
|
||||
background: ThemeLayer;
|
||||
/** Primary text + accent. Most UI chrome reads this. */
|
||||
midground: ThemeLayer;
|
||||
/** Top-layer highlight. In LENS_0 this is white @ alpha 0 — invisible by
|
||||
* default but still drives `--color-ring`-style accents. */
|
||||
foreground: ThemeLayer;
|
||||
/** Legacy palette field — kept for theme YAML compat. */
|
||||
warmGlow: string;
|
||||
/** Legacy palette field — kept for theme YAML compat. */
|
||||
noiseOpacity: number;
|
||||
}
|
||||
|
||||
export interface ThemeTypography {
|
||||
/** CSS font-family stack for sans-serif body copy. */
|
||||
fontSans: string;
|
||||
/** CSS font-family stack for monospace / code blocks. */
|
||||
fontMono: string;
|
||||
/** Optional display/heading font stack. Falls back to `fontSans`. */
|
||||
fontDisplay?: string;
|
||||
/** Optional external stylesheet URL (e.g. Google Fonts, Bunny Fonts,
|
||||
* self-hosted .woff2 @font-face sheet). Injected as a <link> in <head>
|
||||
* on theme switch. Same URL is never injected twice. */
|
||||
fontUrl?: string;
|
||||
/** Root font size (controls rem scale). Example: `"14px"`, `"16px"`. */
|
||||
baseSize: string;
|
||||
/** Default line-height. Example: `"1.5"`, `"1.65"`. */
|
||||
lineHeight: string;
|
||||
/** Default letter-spacing. Example: `"0"`, `"0.01em"`, `"-0.01em"`. */
|
||||
letterSpacing: string;
|
||||
}
|
||||
|
||||
export type ThemeDensity = "compact" | "comfortable" | "spacious";
|
||||
|
||||
export interface ThemeLayout {
|
||||
/** Corner-radius token. Example: `"0"`, `"0.25rem"`, `"0.5rem"`,
|
||||
* `"1rem"`. Maps to `--radius` and cascades into every component. */
|
||||
radius: string;
|
||||
/** Spacing multiplier. `compact` = 0.85, `comfortable` = 1.0 (default),
|
||||
* `spacious` = 1.2. Applied via the `--spacing-mul` CSS var. */
|
||||
density: ThemeDensity;
|
||||
}
|
||||
|
||||
/** Overall layout variant the shell renders. `standard` = default single-
|
||||
* column page layout. `cockpit` = reserves a left sidebar rail for a
|
||||
* plugin slot (intended for HUD-style themes with persistent status panels).
|
||||
* `tiled` = relaxes the main content max-width so pages can use the full
|
||||
* viewport width. Themes set this; plugins react via CSS vars /
|
||||
* `[data-layout-variant="..."]` selectors. */
|
||||
export type ThemeLayoutVariant = "standard" | "cockpit" | "tiled";
|
||||
|
||||
/** Named hero/background assets a theme can populate. Each value is
|
||||
* emitted as a CSS var (`--theme-asset-<name>`). Plugin slots and
|
||||
* shell chrome may consume these via CSS. */
|
||||
export interface ThemeAssets {
|
||||
/** Full-viewport background image URL. Exposed as `--theme-asset-bg` for
|
||||
* the `backdrop` plugin slot or theme `customCSS`. */
|
||||
bg?: string;
|
||||
/** Hero render (Gundam, mascot, wallpaper) — for plugin sidebars/overlays. */
|
||||
hero?: string;
|
||||
/** Logo mark — header slot consumers use this. */
|
||||
logo?: string;
|
||||
/** Faction/brand crest — header-left decoration. */
|
||||
crest?: string;
|
||||
/** Secondary sidebar illustration. */
|
||||
sidebar?: string;
|
||||
/** Alternate header artwork. */
|
||||
header?: string;
|
||||
/** User-defined named assets. Keyed by [a-zA-Z0-9_-] only.
|
||||
* Emitted as `--theme-asset-custom-<key>`. */
|
||||
custom?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Component-style override buckets. Each bucket's entries become CSS
|
||||
* vars (`--component-<bucket>-<kebab-property>`) that shell components
|
||||
* (Card, App header/footer, etc.) read. Values are plain CSS
|
||||
* strings — we don't parse them, so themes can use `clip-path`,
|
||||
* `border-image`, `background`, `box-shadow`, and anything else CSS
|
||||
* accepts. */
|
||||
export interface ThemeComponentStyles {
|
||||
card?: Record<string, string>;
|
||||
header?: Record<string, string>;
|
||||
footer?: Record<string, string>;
|
||||
sidebar?: Record<string, string>;
|
||||
tab?: Record<string, string>;
|
||||
progress?: Record<string, string>;
|
||||
badge?: Record<string, string>;
|
||||
backdrop?: Record<string, string>;
|
||||
page?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Data-series accent colors for chart + table visualisations (Analytics,
|
||||
* Models, etc.). Themes provide hex strings; the provider emits them as
|
||||
* `--series-input-token` / `--series-output-token` CSS vars consumed
|
||||
* inline by pages that render input-vs-output token flows. Themes can
|
||||
* omit either field to inherit the default token defined in
|
||||
* `index.css` (Hermes-teal `#ffe6cb` for input, `#34d399` for output). */
|
||||
export interface ThemeSeriesColors {
|
||||
/** Input-tokens series accent (Analytics chart bars + table values). */
|
||||
inputTokenAccent?: string;
|
||||
/** Output-tokens series accent. */
|
||||
outputTokenAccent?: string;
|
||||
}
|
||||
|
||||
/** Optional hex overrides keyed by shadcn-compat token name (without the
|
||||
* `--color-` prefix). Any key set here wins over the DS cascade. */
|
||||
export interface ThemeColorOverrides {
|
||||
card?: string;
|
||||
cardForeground?: string;
|
||||
popover?: string;
|
||||
popoverForeground?: string;
|
||||
primary?: string;
|
||||
primaryForeground?: string;
|
||||
secondary?: string;
|
||||
secondaryForeground?: string;
|
||||
muted?: string;
|
||||
mutedForeground?: string;
|
||||
accent?: string;
|
||||
accentForeground?: string;
|
||||
destructive?: string;
|
||||
destructiveForeground?: string;
|
||||
success?: string;
|
||||
warning?: string;
|
||||
border?: string;
|
||||
input?: string;
|
||||
ring?: string;
|
||||
}
|
||||
|
||||
export interface DashboardTheme {
|
||||
description: string;
|
||||
label: string;
|
||||
name: string;
|
||||
palette: ThemePalette;
|
||||
typography: ThemeTypography;
|
||||
layout: ThemeLayout;
|
||||
/** Overall shell layout. Defaults to `"standard"` when absent. */
|
||||
layoutVariant?: ThemeLayoutVariant;
|
||||
/** Named + custom asset URLs exposed as CSS vars on theme apply. */
|
||||
assets?: ThemeAssets;
|
||||
/** Raw CSS injected as a scoped `<style>` tag on theme apply, cleaned up
|
||||
* on theme switch. Intended for selector-level chrome that's too
|
||||
* expressive for componentStyles alone (e.g. `::before` pseudo-elements,
|
||||
* complex animations, media queries). */
|
||||
customCSS?: string;
|
||||
/** Per-component CSS-var overrides. See `ThemeComponentStyles`. */
|
||||
componentStyles?: ThemeComponentStyles;
|
||||
colorOverrides?: ThemeColorOverrides;
|
||||
/** Data-series accent colors for Analytics/Models token charts. */
|
||||
seriesColors?: ThemeSeriesColors;
|
||||
/** Explicit 3-color swatch override for the theme picker. Order matches the
|
||||
* default swatch cells: [background, midground, warmGlow]. */
|
||||
swatchColors?: [string, string, string];
|
||||
/** Background color for the embedded terminal pane (xterm.js).
|
||||
* Hex string. Defaults to `"#000000"` when absent. */
|
||||
terminalBackground?: string;
|
||||
/** Default text/cursor color for the embedded terminal pane (xterm.js).
|
||||
* Hex string. Defaults to `"#f0e6d2"` when absent. */
|
||||
terminalForeground?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire response shape for `GET /api/dashboard/themes`.
|
||||
*
|
||||
* The `themes` list is intentionally partial — built-in themes are fully
|
||||
* defined in `presets.ts`; user themes carry their full definition so the
|
||||
* client can apply them without a second round-trip.
|
||||
*/
|
||||
export interface ThemeListEntry {
|
||||
description: string;
|
||||
label: string;
|
||||
name: string;
|
||||
/** Full theme definition. Present for user-defined themes loaded from
|
||||
* `~/.hermes/dashboard-themes/*.yaml`; undefined for built-ins (the
|
||||
* client already has those in `BUILTIN_THEMES`). */
|
||||
definition?: DashboardTheme;
|
||||
}
|
||||
|
||||
export interface ThemeListResponse {
|
||||
active: string;
|
||||
themes: ThemeListEntry[];
|
||||
}
|
||||
Reference in New Issue
Block a user