mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(dashboard): expand themes to fonts, layout, density (#14725)
Dashboard themes now control typography and layout, not just colors. Each built-in theme picks its own fonts, base size, radius, and density so switching produces visible changes beyond hue. Schema additions (per theme): - typography — fontSans, fontMono, fontDisplay, fontUrl, baseSize, lineHeight, letterSpacing. fontUrl is injected as <link> on switch so Google/Bunny/self-hosted stylesheets all work. - layout — radius (any CSS length) and density (compact | comfortable | spacious, multiplies Tailwind spacing). - colorOverrides (optional) — pin individual shadcn tokens that would otherwise derive from the palette. Built-in themes are now distinct beyond palette: - default — system stack, 15px, 0.5rem radius, comfortable - midnight — Inter + JetBrains Mono, 14px, 0.75rem, comfortable - ember — Spectral (serif) + IBM Plex Mono, 15px, 0.25rem - mono — IBM Plex Sans + Mono, 13px, 0 radius, compact - cyberpunk— Share Tech Mono everywhere, 14px, 0 radius, compact - rose — Fraunces (serif) + DM Mono, 16px, 1rem, spacious Also fixes two bugs: 1. Custom user themes silently fell back to default. ThemeProvider only applied BUILTIN_THEMES[name], so YAML files in ~/.hermes/dashboard-themes/ showed in the picker but did nothing. Server now ships the full normalised definition; client applies it. 2. Docs documented a 21-token flat colors schema that never matched the code (applyPalette reads a 3-layer palette). Rewrote the Themes section against the actual shape. Implementation: - web/src/themes/types.ts: extend DashboardTheme with typography, layout, colorOverrides; ThemeListEntry carries optional definition. - web/src/themes/presets.ts: 6 built-ins with distinct typography+layout. - web/src/themes/context.tsx: applyTheme() writes palette+typography+ layout+overrides as CSS vars, injects fontUrl stylesheet, fixes the fallback-to-default bug via resolveTheme(name). - web/src/index.css: html/body/code read the new theme-font vars; --radius-sm/md/lg/xl derive from --theme-radius; --spacing scales with --theme-spacing-mul so Tailwind utilities shift with density. - hermes_cli/web_server.py: _normalise_theme_definition() parses loose YAML (bare hex strings, partial blocks) into the canonical wire shape; /api/dashboard/themes ships full definitions for user themes. - tests/hermes_cli/test_web_server.py: 16 new tests covering the normaliser and discovery (rejection cases, clamping, defaults). - website/docs/user-guide/features/web-dashboard.md: rewrite Themes section with real schema, per-model tables, full YAML example.
This commit is contained in:
parent
8f5fee3e3e
commit
255ba5bf26
8 changed files with 898 additions and 92 deletions
|
|
@ -29,6 +29,48 @@
|
|||
/* Consumed by <Backdrop />; also theme-switchable. */
|
||||
--warm-glow: rgba(255, 189, 56, 0.35);
|
||||
--noise-opacity-mul: 1;
|
||||
|
||||
/* Typography tokens — rewritten by ThemeProvider. Defaults match the
|
||||
system stack so themes that don't override look native. */
|
||||
--theme-font-sans: system-ui, -apple-system, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
--theme-font-mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo,
|
||||
Consolas, monospace;
|
||||
--theme-font-display: var(--theme-font-sans);
|
||||
--theme-base-size: 15px;
|
||||
--theme-line-height: 1.55;
|
||||
--theme-letter-spacing: 0;
|
||||
|
||||
/* Layout tokens. */
|
||||
--radius: 0.5rem;
|
||||
--theme-radius: 0.5rem;
|
||||
--theme-spacing-mul: 1;
|
||||
--theme-density: comfortable;
|
||||
}
|
||||
|
||||
/* Theme tokens cascade into the document root so every descendant inherits
|
||||
the font stack, base size, and letter spacing without explicit calls. */
|
||||
html {
|
||||
font-family: var(--theme-font-sans);
|
||||
font-size: var(--theme-base-size);
|
||||
line-height: var(--theme-line-height);
|
||||
letter-spacing: var(--theme-letter-spacing);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--theme-font-sans);
|
||||
}
|
||||
|
||||
code, kbd, pre, samp, .font-mono, .font-mono-ui {
|
||||
font-family: var(--theme-font-mono);
|
||||
}
|
||||
|
||||
/* Density: scale the shadcn spacing utilities via a multiplier. The DS
|
||||
components use `p-N` / `gap-N` / `space-*` classes which resolve against
|
||||
Tailwind's spacing scale; multiplying `--spacing` at :root scales them
|
||||
all proportionally in Tailwind v4. */
|
||||
@theme inline {
|
||||
--spacing: calc(0.25rem * var(--theme-spacing-mul, 1));
|
||||
}
|
||||
|
||||
/* Nousnet's hermes-agent layout bumps `small` and `code` to readable
|
||||
|
|
@ -65,6 +107,11 @@ code { font-size: 0.875rem; }
|
|||
--color-ring: var(--midground);
|
||||
--color-popover: color-mix(in srgb, var(--midground-base) 4%, var(--background-base));
|
||||
--color-popover-foreground: var(--midground);
|
||||
|
||||
--radius-sm: calc(var(--theme-radius) - 4px);
|
||||
--radius-md: calc(var(--theme-radius) - 2px);
|
||||
--radius-lg: var(--theme-radius);
|
||||
--radius-xl: calc(var(--theme-radius) + 4px);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -94,9 +141,11 @@ code { font-size: 0.875rem; }
|
|||
|
||||
/* System UI-monospace stack — distinct from `font-courier` (Courier
|
||||
Prime), used for dense data readouts where the display font would
|
||||
break the grid. */
|
||||
break the grid. Routes through the theme's mono stack so themes
|
||||
with a different monospace (JetBrains Mono, IBM Plex Mono, etc.)
|
||||
still apply here. */
|
||||
.font-mono-ui {
|
||||
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, monospace;
|
||||
font-family: var(--theme-font-mono);
|
||||
}
|
||||
|
||||
/* Subtle grain overlay for badges. */
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
const BASE = "";
|
||||
|
||||
import type { DashboardTheme } from "@/themes/types";
|
||||
|
||||
// Ephemeral session token for protected endpoints.
|
||||
// Injected into index.html by the server — never fetched via API.
|
||||
declare global {
|
||||
|
|
@ -486,6 +488,9 @@ export interface DashboardThemeSummary {
|
|||
description: string;
|
||||
label: string;
|
||||
name: string;
|
||||
/** Full theme definition for user themes; undefined for built-ins
|
||||
* (which the frontend already has locally). */
|
||||
definition?: DashboardTheme;
|
||||
}
|
||||
|
||||
export interface DashboardThemesResponse {
|
||||
|
|
|
|||
|
|
@ -8,16 +8,35 @@ import {
|
|||
type ReactNode,
|
||||
} from "react";
|
||||
import { BUILTIN_THEMES, defaultTheme } from "./presets";
|
||||
import type { DashboardTheme, ThemeLayer, ThemePalette } from "./types";
|
||||
import type {
|
||||
DashboardTheme,
|
||||
ThemeColorOverrides,
|
||||
ThemeDensity,
|
||||
ThemeLayer,
|
||||
ThemeLayout,
|
||||
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 <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) {
|
||||
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)`,
|
||||
|
|
@ -26,28 +45,145 @@ function layerVars(name: "background" | "midground" | "foreground", layer: Theme
|
|||
};
|
||||
}
|
||||
|
||||
/** 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;
|
||||
const vars = {
|
||||
function paletteVars(palette: ThemePalette): Record<string, string> {
|
||||
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<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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
|
||||
const vars = {
|
||||
...paletteVars(theme.palette),
|
||||
...typographyVars(theme.typography),
|
||||
...layoutVars(theme.layout),
|
||||
...overrideVars(theme.colorOverrides),
|
||||
};
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
root.style.setProperty(k, v);
|
||||
}
|
||||
|
||||
injectFontStylesheet(theme.typography.fontUrl);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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";
|
||||
return window.localStorage.getItem(STORAGE_KEY) ?? "default";
|
||||
});
|
||||
|
||||
/** 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<
|
||||
Array<{ description: string; label: string; name: string }>
|
||||
>(() =>
|
||||
|
|
@ -58,18 +194,56 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||
})),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const t = BUILTIN_THEMES[themeName] ?? defaultTheme;
|
||||
applyPalette(t.palette);
|
||||
}, [themeName]);
|
||||
/** 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>
|
||||
>({});
|
||||
|
||||
// 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],
|
||||
);
|
||||
|
||||
// Re-apply on every themeName change, or when user themes arrive from
|
||||
// the API (since the active theme might be a user theme whose definition
|
||||
// hadn't loaded yet on first render).
|
||||
useEffect(() => {
|
||||
applyTheme(resolveTheme(themeName));
|
||||
}, [themeName, resolveTheme]);
|
||||
|
||||
// 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);
|
||||
if (resp.themes?.length) {
|
||||
setAvailableThemes(
|
||||
resp.themes.map((t) => ({
|
||||
name: t.name,
|
||||
label: t.label,
|
||||
description: t.description,
|
||||
})),
|
||||
);
|
||||
// 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 && resp.active !== themeName) {
|
||||
setThemeName(resp.active);
|
||||
window.localStorage.setItem(STORAGE_KEY, resp.active);
|
||||
|
|
@ -79,23 +253,35 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
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) => {
|
||||
// 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 value = useMemo<ThemeContextValue>(
|
||||
() => ({
|
||||
theme: BUILTIN_THEMES[themeName] ?? defaultTheme,
|
||||
theme: resolveTheme(themeName),
|
||||
themeName,
|
||||
availableThemes,
|
||||
setTheme,
|
||||
}),
|
||||
[themeName, availableThemes, setTheme],
|
||||
[themeName, availableThemes, setTheme, resolveTheme],
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
|
|
|
|||
|
|
@ -1,17 +1,43 @@
|
|||
import type { DashboardTheme } from "./types";
|
||||
import type { DashboardTheme, ThemeTypography, ThemeLayout } from "./types";
|
||||
|
||||
/**
|
||||
* Built-in dashboard themes.
|
||||
*
|
||||
* The `default` theme mirrors LENS_0 (canonical Hermes teal) exactly — the
|
||||
* same triplet `src/index.css` declares on `:root`. Applying it should be a
|
||||
* visual no-op; other themes override the triplet + warm-glow and let the DS
|
||||
* cascade handle every derived surface.
|
||||
* 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",
|
||||
|
|
@ -23,6 +49,8 @@ export const defaultTheme: DashboardTheme = {
|
|||
warmGlow: "rgba(255, 189, 56, 0.35)",
|
||||
noiseOpacity: 1,
|
||||
},
|
||||
typography: DEFAULT_TYPOGRAPHY,
|
||||
layout: DEFAULT_LAYOUT,
|
||||
};
|
||||
|
||||
export const midnightTheme: DashboardTheme = {
|
||||
|
|
@ -36,6 +64,19 @@ export const midnightTheme: DashboardTheme = {
|
|||
warmGlow: "rgba(167, 139, 250, 0.32)",
|
||||
noiseOpacity: 0.8,
|
||||
},
|
||||
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",
|
||||
baseSize: "14px",
|
||||
lineHeight: "1.6",
|
||||
letterSpacing: "-0.005em",
|
||||
},
|
||||
layout: {
|
||||
radius: "0.75rem",
|
||||
density: "comfortable",
|
||||
},
|
||||
};
|
||||
|
||||
export const emberTheme: DashboardTheme = {
|
||||
|
|
@ -49,6 +90,23 @@ export const emberTheme: DashboardTheme = {
|
|||
warmGlow: "rgba(249, 115, 22, 0.38)",
|
||||
noiseOpacity: 1,
|
||||
},
|
||||
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",
|
||||
baseSize: "15px",
|
||||
lineHeight: "1.6",
|
||||
letterSpacing: "0",
|
||||
},
|
||||
layout: {
|
||||
radius: "0.25rem",
|
||||
density: "comfortable",
|
||||
},
|
||||
colorOverrides: {
|
||||
destructive: "#c92d0f",
|
||||
warning: "#f97316",
|
||||
},
|
||||
};
|
||||
|
||||
export const monoTheme: DashboardTheme = {
|
||||
|
|
@ -62,6 +120,19 @@ export const monoTheme: DashboardTheme = {
|
|||
warmGlow: "rgba(255, 255, 255, 0.1)",
|
||||
noiseOpacity: 0.6,
|
||||
},
|
||||
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",
|
||||
baseSize: "13px",
|
||||
lineHeight: "1.5",
|
||||
letterSpacing: "0",
|
||||
},
|
||||
layout: {
|
||||
radius: "0",
|
||||
density: "compact",
|
||||
},
|
||||
};
|
||||
|
||||
export const cyberpunkTheme: DashboardTheme = {
|
||||
|
|
@ -75,6 +146,24 @@ export const cyberpunkTheme: DashboardTheme = {
|
|||
warmGlow: "rgba(0, 255, 136, 0.22)",
|
||||
noiseOpacity: 1.2,
|
||||
},
|
||||
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",
|
||||
baseSize: "14px",
|
||||
lineHeight: "1.5",
|
||||
letterSpacing: "0.02em",
|
||||
},
|
||||
layout: {
|
||||
radius: "0",
|
||||
density: "compact",
|
||||
},
|
||||
colorOverrides: {
|
||||
success: "#00ff88",
|
||||
warning: "#ffd700",
|
||||
destructive: "#ff0055",
|
||||
},
|
||||
};
|
||||
|
||||
export const roseTheme: DashboardTheme = {
|
||||
|
|
@ -88,6 +177,19 @@ export const roseTheme: DashboardTheme = {
|
|||
warmGlow: "rgba(249, 168, 212, 0.3)",
|
||||
noiseOpacity: 0.9,
|
||||
},
|
||||
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",
|
||||
baseSize: "16px",
|
||||
lineHeight: "1.7",
|
||||
letterSpacing: "0",
|
||||
},
|
||||
layout: {
|
||||
radius: "1rem",
|
||||
density: "spacious",
|
||||
},
|
||||
};
|
||||
|
||||
export const BUILTIN_THEMES: Record<string, DashboardTheme> = {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,22 @@
|
|||
/**
|
||||
* Dashboard theme model.
|
||||
*
|
||||
* Unlike the pre-DS implementation (which overrode 21 shadcn tokens directly),
|
||||
* themes are now expressed in the Nous DS's own 3-triplet vocabulary —
|
||||
* `background`, `midground`, `foreground` — plus a warm-glow tint for the
|
||||
* vignette in <Backdrop />. All downstream shadcn-compat tokens
|
||||
* (`--color-card`, `--color-muted-foreground`, `--color-border`, etc.) are
|
||||
* defined in `src/index.css` as `color-mix()` expressions over the triplets,
|
||||
* so overriding the triplets at runtime cascades to every surface.
|
||||
* Themes customise three orthogonal layers:
|
||||
*
|
||||
* 1. `palette` — the 3-layer color triplet (background/midground/
|
||||
* foreground) + warm-glow + noise opacity. The
|
||||
* design-system cascade in `src/index.css` derives
|
||||
* every shadcn-compat token (card, muted, border,
|
||||
* primary, etc.) from this triplet via `color-mix()`.
|
||||
* 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). */
|
||||
|
|
@ -31,14 +40,88 @@ export interface ThemePalette {
|
|||
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;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
colorOverrides?: ThemeColorOverrides;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: Array<{ description: string; label: string; name: string }>;
|
||||
themes: ThemeListEntry[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue