mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
feat(dashboard): change UI font from the theme picker, independent of theme (#41145)
The dashboard font is now selectable from the UI, not just YAML. A new Font section in the header theme picker overrides the UI font of whatever theme is active; the choice is orthogonal to the theme and survives theme switches. Each theme keeps its own font as the default — picking "Theme default" clears the override. - web/src/themes/fonts.ts: curated font catalog (system + Google Fonts across sans/serif/mono), each with a family stack and optional webfont URL. The catalog is the only injected-font surface — no free-text URL box, so the injected <link> origins stay fixed. - web/src/themes/context.tsx: font-override state (localStorage + server), applied after theme typography so it wins; theme apply re-asserts it, and clearing re-runs theme apply to restore the theme's own font. Mono is left to the theme so code/terminal are untouched. - web/src/components/ThemeSwitcher.tsx: Font section with grouped, self- previewing font rows and a "Theme default" clear option. - hermes_cli/web_server.py: GET/PUT /api/dashboard/font persisting to config.yaml dashboard.font, with a server-side id allow-list (unknown ids coerce to the theme sentinel). - i18n + types, api client methods, tests, and docs. Validation: 6 new backend endpoint tests pass; tsc + vite build clean; live browser test confirmed pick/persist/survive-theme-switch/clear all work.
This commit is contained in:
parent
136dae779e
commit
9e63109522
11 changed files with 551 additions and 9 deletions
|
|
@ -9221,6 +9221,52 @@ async def set_dashboard_theme(body: ThemeSetBody):
|
|||
return {"ok": True, "theme": body.name}
|
||||
|
||||
|
||||
# Curated font-override ids. Kept in sync with FONT_CHOICES in
|
||||
# web/src/themes/fonts.ts — the frontend owns the stacks + webfont URLs;
|
||||
# the backend only needs the id allow-list so it can reject anything not
|
||||
# in the vetted catalog (the font's webfont URL is injected as a <link>,
|
||||
# so we never accept an arbitrary user-supplied id/URL here).
|
||||
_FONT_DEFAULT_ID = "theme"
|
||||
_FONT_CHOICES = frozenset({
|
||||
"system-sans", "system-serif", "system-mono",
|
||||
"inter", "ibm-plex-sans", "work-sans", "atkinson-hyperlegible", "dm-sans",
|
||||
"spectral", "fraunces", "source-serif",
|
||||
"jetbrains-mono", "ibm-plex-mono", "space-mono",
|
||||
})
|
||||
|
||||
|
||||
@app.get("/api/dashboard/font")
|
||||
async def get_dashboard_font():
|
||||
"""Return the active font override (``"theme"`` = use the theme's font)."""
|
||||
config = load_config()
|
||||
font = cfg_get(config, "dashboard", "font", default=_FONT_DEFAULT_ID)
|
||||
if font not in _FONT_CHOICES:
|
||||
font = _FONT_DEFAULT_ID
|
||||
return {"font": font}
|
||||
|
||||
|
||||
class FontSetBody(BaseModel):
|
||||
font: str
|
||||
|
||||
|
||||
@app.put("/api/dashboard/font")
|
||||
async def set_dashboard_font(body: FontSetBody):
|
||||
"""Set the dashboard font override (persists to config.yaml).
|
||||
|
||||
Accepts any id in the curated catalog, or ``"theme"`` to clear the
|
||||
override and fall back to the active theme's own font. Unknown ids are
|
||||
coerced to ``"theme"`` rather than 400'd so a stale client can't wedge
|
||||
the picker.
|
||||
"""
|
||||
font = body.font if body.font in _FONT_CHOICES else _FONT_DEFAULT_ID
|
||||
config = load_config()
|
||||
if "dashboard" not in config:
|
||||
config["dashboard"] = {}
|
||||
config["dashboard"]["font"] = font
|
||||
save_config(config)
|
||||
return {"ok": True, "font": font}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard plugin system
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -243,6 +243,69 @@ class TestWebServerEndpoints:
|
|||
assert "hermes_home" in data
|
||||
assert "active_sessions" in data
|
||||
|
||||
# ── Dashboard font override ─────────────────────────────────────────
|
||||
|
||||
def test_get_dashboard_font_defaults_to_theme(self):
|
||||
"""With no override persisted, the active font is the theme sentinel."""
|
||||
resp = self.client.get("/api/dashboard/font")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"font": "theme"}
|
||||
|
||||
def test_set_dashboard_font_persists_valid_choice(self):
|
||||
"""A valid catalog id is accepted, persisted, and read back."""
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
resp = self.client.put("/api/dashboard/font", json={"font": "inter"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True, "font": "inter"}
|
||||
|
||||
# Persisted to config.yaml under dashboard.font.
|
||||
config = load_config()
|
||||
assert config["dashboard"]["font"] == "inter"
|
||||
|
||||
# And reflected by the GET endpoint.
|
||||
assert self.client.get("/api/dashboard/font").json() == {"font": "inter"}
|
||||
|
||||
def test_set_dashboard_font_clears_with_theme_sentinel(self):
|
||||
"""Setting 'theme' clears any prior override."""
|
||||
self.client.put("/api/dashboard/font", json={"font": "fraunces"})
|
||||
resp = self.client.put("/api/dashboard/font", json={"font": "theme"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True, "font": "theme"}
|
||||
assert self.client.get("/api/dashboard/font").json() == {"font": "theme"}
|
||||
|
||||
def test_set_dashboard_font_rejects_unknown_id(self):
|
||||
"""An id not in the curated catalog coerces to the theme sentinel,
|
||||
so a stale/hostile client can't inject an arbitrary font id."""
|
||||
resp = self.client.put(
|
||||
"/api/dashboard/font", json={"font": "../../etc/passwd"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True, "font": "theme"}
|
||||
|
||||
def test_get_dashboard_font_coerces_stale_persisted_value(self):
|
||||
"""A config value no longer in the catalog reads back as 'theme'."""
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
config = load_config()
|
||||
config.setdefault("dashboard", {})["font"] = "retired-font-id"
|
||||
save_config(config)
|
||||
|
||||
assert self.client.get("/api/dashboard/font").json() == {"font": "theme"}
|
||||
|
||||
def test_dashboard_font_override_independent_of_theme(self):
|
||||
"""The font override and the theme are stored separately — setting
|
||||
one must not disturb the other."""
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
self.client.put("/api/dashboard/theme", json={"name": "ember"})
|
||||
self.client.put("/api/dashboard/font", json={"font": "jetbrains-mono"})
|
||||
|
||||
config = load_config()
|
||||
assert config["dashboard"]["theme"] == "ember"
|
||||
assert config["dashboard"]["font"] == "jetbrains-mono"
|
||||
|
||||
|
||||
def test_get_sessions_uses_only_persisted_cwd(self, monkeypatch):
|
||||
"""Session rows without persisted cwd must not inherit TERMINAL_CWD.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Palette, Check } from "lucide-react";
|
||||
import { Palette, Check, Type } from "lucide-react";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
||||
import { BottomSheet } from "@nous-research/ui/ui/components/bottom-sheet";
|
||||
import { Typography } from "@nous-research/ui/ui/components/typography/index";
|
||||
import { useBelowBreakpoint } from "@nous-research/ui/hooks/use-below-breakpoint";
|
||||
import { BUILTIN_THEMES, useTheme } from "@/themes";
|
||||
import type { DashboardTheme, ThemeListEntry } from "@/themes";
|
||||
import { BUILTIN_THEMES, THEME_DEFAULT_FONT_ID, useTheme } from "@/themes";
|
||||
import type { DashboardTheme, FontChoice, ThemeListEntry } from "@/themes";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ import { cn } from "@/lib/utils";
|
|||
* the sidebar (same idea as a responsive Drawer).
|
||||
*/
|
||||
export function ThemeSwitcher({ collapsed = false, dropUp = false }: ThemeSwitcherProps) {
|
||||
const { themeName, availableThemes, setTheme } = useTheme();
|
||||
const { themeName, availableThemes, setTheme, fontId, fontChoices, setFont } = useTheme();
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -104,6 +104,11 @@ export function ThemeSwitcher({ collapsed = false, dropUp = false }: ThemeSwitch
|
|||
setTheme={setTheme}
|
||||
themeName={themeName}
|
||||
/>
|
||||
<FontSection
|
||||
fontChoices={fontChoices}
|
||||
fontId={fontId}
|
||||
setFont={setFont}
|
||||
/>
|
||||
</div>
|
||||
</BottomSheet>
|
||||
)}
|
||||
|
|
@ -142,6 +147,11 @@ export function ThemeSwitcher({ collapsed = false, dropUp = false }: ThemeSwitch
|
|||
setTheme={setTheme}
|
||||
themeName={themeName}
|
||||
/>
|
||||
<FontSection
|
||||
fontChoices={fontChoices}
|
||||
fontId={fontId}
|
||||
setFont={setFont}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return dropUp ? createPortal(dropdown, document.body) : dropdown;
|
||||
|
|
@ -207,6 +217,105 @@ function ThemeSwitcherOptions({
|
|||
);
|
||||
}
|
||||
|
||||
const FONT_CATEGORY_LABEL_KEY: Record<FontChoice["category"], "fontSans" | "fontSerif" | "fontMono"> = {
|
||||
sans: "fontSans",
|
||||
serif: "fontSerif",
|
||||
mono: "fontMono",
|
||||
};
|
||||
|
||||
/** Font-override section rendered below the theme list. Lets the user pick
|
||||
* any catalog font independently of the active theme, or "Theme default"
|
||||
* to clear the override. Each row previews itself in its own font. */
|
||||
function FontSection({ fontChoices, fontId, setFont }: FontSectionProps) {
|
||||
const { t } = useI18n();
|
||||
const order: FontChoice["category"][] = ["sans", "serif", "mono"];
|
||||
return (
|
||||
<>
|
||||
<div className="mt-1 border-t border-current/20 px-3 pb-1 pt-2">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Type className="h-3 w-3 text-text-tertiary" />
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-display text-xs tracking-[0.12em] text-text-tertiary"
|
||||
>
|
||||
{t.theme?.fontTitle ?? "Font"}
|
||||
</Typography>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Theme-default (clears the override). */}
|
||||
<ListItem
|
||||
active={fontId === THEME_DEFAULT_FONT_ID}
|
||||
aria-selected={fontId === THEME_DEFAULT_FONT_ID}
|
||||
className="gap-3"
|
||||
onClick={() => setFont(THEME_DEFAULT_FONT_ID)}
|
||||
role="option"
|
||||
>
|
||||
<span aria-hidden className="h-4 w-9 shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<Typography className="truncate text-xs tracking-normal">
|
||||
{t.theme?.fontDefault ?? "Theme default"}
|
||||
</Typography>
|
||||
<Typography className="truncate text-xs tracking-normal text-text-tertiary">
|
||||
{t.theme?.fontDefaultHint ?? "Use the active theme's font"}
|
||||
</Typography>
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
"h-3 w-3 shrink-0 text-midground",
|
||||
fontId === THEME_DEFAULT_FONT_ID ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
{order.map((cat) => {
|
||||
const fonts = fontChoices.filter((f) => f.category === cat);
|
||||
if (fonts.length === 0) return null;
|
||||
const catLabel = t.theme?.[FONT_CATEGORY_LABEL_KEY[cat]] ?? cat;
|
||||
return (
|
||||
<div key={cat}>
|
||||
<div className="px-3 pb-0.5 pt-1.5">
|
||||
<Typography className="text-[0.65rem] uppercase tracking-[0.1em] text-text-tertiary">
|
||||
{catLabel}
|
||||
</Typography>
|
||||
</div>
|
||||
{fonts.map((f) => {
|
||||
const isActive = f.id === fontId;
|
||||
return (
|
||||
<ListItem
|
||||
active={isActive}
|
||||
aria-selected={isActive}
|
||||
className="gap-3"
|
||||
key={f.id}
|
||||
onClick={() => setFont(f.id)}
|
||||
role="option"
|
||||
>
|
||||
<span aria-hidden className="h-4 w-9 shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{/* Preview the font in its own stack. */}
|
||||
<span
|
||||
className="truncate text-sm"
|
||||
style={{ fontFamily: f.stack }}
|
||||
>
|
||||
{f.label}
|
||||
</span>
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
"h-3 w-3 shrink-0 text-midground",
|
||||
isActive ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeSwatch({ theme }: { theme: DashboardTheme }) {
|
||||
// Inverted themes (Nous Blue / future lens themes) author their palette
|
||||
// pre-inversion — `#FFAC02` reads as `#0053FD` blue once the foreground-
|
||||
|
|
@ -247,6 +356,12 @@ interface ThemeSwitcherOptionsProps {
|
|||
themeName: string;
|
||||
}
|
||||
|
||||
interface FontSectionProps {
|
||||
fontChoices: FontChoice[];
|
||||
fontId: string;
|
||||
setFont: (id: string) => void;
|
||||
}
|
||||
|
||||
interface ThemeSwitcherProps {
|
||||
collapsed?: boolean;
|
||||
dropUp?: boolean;
|
||||
|
|
|
|||
|
|
@ -518,6 +518,12 @@ export const en: Translations = {
|
|||
theme: {
|
||||
title: "Theme",
|
||||
switchTheme: "Switch theme",
|
||||
fontTitle: "Font",
|
||||
fontDefault: "Theme default",
|
||||
fontDefaultHint: "Use the active theme's font",
|
||||
fontSans: "Sans",
|
||||
fontSerif: "Serif",
|
||||
fontMono: "Mono",
|
||||
},
|
||||
|
||||
achievements: {
|
||||
|
|
|
|||
|
|
@ -539,6 +539,13 @@ export interface Translations {
|
|||
theme: {
|
||||
title: string;
|
||||
switchTheme: string;
|
||||
/** Font-override section (optional — locales fall back to English). */
|
||||
fontTitle?: string;
|
||||
fontDefault?: string;
|
||||
fontDefaultHint?: string;
|
||||
fontSans?: string;
|
||||
fontSerif?: string;
|
||||
fontMono?: string;
|
||||
};
|
||||
|
||||
// ── Achievements plugin (plugins/hermes-achievements) ──
|
||||
|
|
|
|||
|
|
@ -741,6 +741,14 @@ export const api = {
|
|||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
getFontPref: () =>
|
||||
fetchJSON<DashboardFontResponse>("/api/dashboard/font"),
|
||||
setFontPref: (font: string) =>
|
||||
fetchJSON<{ ok: boolean; font: string }>("/api/dashboard/font", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ font }),
|
||||
}),
|
||||
|
||||
// ── Admin: MCP servers ──────────────────────────────────────────────
|
||||
getMcpServers: () => fetchJSON<{ servers: McpServer[] }>("/api/mcp/servers"),
|
||||
|
|
@ -1857,6 +1865,11 @@ export interface DashboardThemesResponse {
|
|||
themes: DashboardThemeSummary[];
|
||||
}
|
||||
|
||||
export interface DashboardFontResponse {
|
||||
/** Active font-override id, or "theme" when no override is set. */
|
||||
font: string;
|
||||
}
|
||||
|
||||
// ── Dashboard plugin types ─────────────────────────────────────────────
|
||||
|
||||
export interface PluginManifestResponse {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@ import {
|
|||
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,
|
||||
|
|
@ -28,6 +34,12 @@ import { api } from "@/lib/api";
|
|||
* 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`
|
||||
|
|
@ -296,6 +308,40 @@ function injectFontStylesheet(url: string | undefined) {
|
|||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -350,6 +396,10 @@ function applyTheme(theme: DashboardTheme) {
|
|||
"--theme-terminal-background",
|
||||
theme.terminalBackground ?? "#000000",
|
||||
);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -386,6 +436,16 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||
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(
|
||||
|
|
@ -399,12 +459,14 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||
[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).
|
||||
// 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]);
|
||||
}, [themeName, resolveTheme, fontId]);
|
||||
|
||||
// Load server-side themes (built-ins + user YAMLs) once on mount.
|
||||
useEffect(() => {
|
||||
|
|
@ -452,6 +514,30 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||
// 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.
|
||||
|
|
@ -470,14 +556,26 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||
[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],
|
||||
[themeName, availableThemes, setTheme, resolveTheme, fontId, setFont],
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
|
|
@ -496,6 +594,9 @@ const ThemeContext = createContext<ThemeContextValue>({
|
|||
description: t.description,
|
||||
})),
|
||||
setTheme: () => {},
|
||||
fontId: THEME_DEFAULT_FONT_ID,
|
||||
fontChoices: FONT_CHOICES,
|
||||
setFont: () => {},
|
||||
});
|
||||
|
||||
interface ThemeContextValue {
|
||||
|
|
@ -503,4 +604,10 @@ interface ThemeContextValue {
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
160
web/src/themes/fonts.ts
Normal file
160
web/src/themes/fonts.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,3 +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";
|
||||
|
|
|
|||
|
|
@ -131,6 +131,22 @@ typography:
|
|||
letterSpacing: "0.04em"
|
||||
```
|
||||
|
||||
##### Changing the font from the UI (no YAML)
|
||||
|
||||
The theme picker in the dashboard header has a **Font** section below the
|
||||
theme list. Pick any font there and it overrides the body font of whatever
|
||||
theme is active — the choice is independent of the theme and persists across
|
||||
theme switches (stored in `config.yaml` under `dashboard.font`). Choose
|
||||
**Theme default** to clear the override and fall back to the active theme's
|
||||
own `fontSans`.
|
||||
|
||||
The picker offers a curated catalog (system stacks plus a set of Google-Fonts
|
||||
families across sans / serif / mono). It deliberately does **not** accept a
|
||||
free-text font URL — the font's stylesheet is injected as a `<link>`, so the
|
||||
catalog keeps the injected origins fixed. For a fully custom face, set
|
||||
`fontSans` + `fontUrl` in a theme YAML as shown above. The theme's `fontMono`
|
||||
(code blocks, terminal) is always left untouched by the UI override.
|
||||
|
||||
#### Layout
|
||||
|
||||
| Key | Values | Description |
|
||||
|
|
|
|||
|
|
@ -1023,6 +1023,8 @@ The dashboard ships with six built-in themes and can be extended with user-defin
|
|||
|
||||
**Switch themes live** from the header bar — click the palette icon next to the language switcher. Selection persists to `config.yaml` under `dashboard.theme` and is restored on page load.
|
||||
|
||||
**Change the font independently** from the same picker — the **Font** section below the theme list overrides the UI font of whatever theme is active. The choice persists across theme switches (`config.yaml` → `dashboard.font`); pick **Theme default** to clear it and return to the active theme's own font.
|
||||
|
||||
Built-in themes:
|
||||
|
||||
| Theme | Character |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue