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:
Teknium
2026-06-07 03:39:01 -07:00
committed by GitHub
parent 136dae779e
commit 9e63109522
11 changed files with 551 additions and 9 deletions
+112 -5
View File
@@ -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
View 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;
}
+7
View File
@@ -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";