From 9e63109522cd0037670cf60d38714eab009efa46 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 7 Jun 2026 03:39:01 -0700 Subject: [PATCH] feat(dashboard): change UI font from the theme picker, independent of theme (#41145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- hermes_cli/web_server.py | 46 +++++ tests/hermes_cli/test_web_server.py | 63 +++++++ web/src/components/ThemeSwitcher.tsx | 123 +++++++++++++- web/src/i18n/en.ts | 6 + web/src/i18n/types.ts | 7 + web/src/lib/api.ts | 13 ++ web/src/themes/context.tsx | 117 ++++++++++++- web/src/themes/fonts.ts | 160 ++++++++++++++++++ web/src/themes/index.ts | 7 + .../features/extending-the-dashboard.md | 16 ++ .../docs/user-guide/features/web-dashboard.md | 2 + 11 files changed, 551 insertions(+), 9 deletions(-) create mode 100644 web/src/themes/fonts.ts diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index dca6984716e..40a36c36418 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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 , +# 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 # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 527d0939a38..278c0ee3432 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -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. diff --git a/web/src/components/ThemeSwitcher.tsx b/web/src/components/ThemeSwitcher.tsx index 161175bbfbc..9bbab6ef26b 100644 --- a/web/src/components/ThemeSwitcher.tsx +++ b/web/src/components/ThemeSwitcher.tsx @@ -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(null); @@ -104,6 +104,11 @@ export function ThemeSwitcher({ collapsed = false, dropUp = false }: ThemeSwitch setTheme={setTheme} themeName={themeName} /> + )} @@ -142,6 +147,11 @@ export function ThemeSwitcher({ collapsed = false, dropUp = false }: ThemeSwitch setTheme={setTheme} themeName={themeName} /> + ); return dropUp ? createPortal(dropdown, document.body) : dropdown; @@ -207,6 +217,105 @@ function ThemeSwitcherOptions({ ); } +const FONT_CATEGORY_LABEL_KEY: Record = { + 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 ( + <> +
+ + + + {t.theme?.fontTitle ?? "Font"} + + +
+ + {/* Theme-default (clears the override). */} + setFont(THEME_DEFAULT_FONT_ID)} + role="option" + > + +
+ + {t.theme?.fontDefault ?? "Theme default"} + + + {t.theme?.fontDefaultHint ?? "Use the active theme's font"} + +
+ +
+ + {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 ( +
+
+ + {catLabel} + +
+ {fonts.map((f) => { + const isActive = f.id === fontId; + return ( + setFont(f.id)} + role="option" + > + +
+ {/* Preview the font in its own stack. */} + + {f.label} + +
+ +
+ ); + })} +
+ ); + })} + + ); +} + 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; diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 9cde64dec65..8203c25bd52 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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: { diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index 6d745ba763f..14bc41f2d08 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -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) ── diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index cbcb7fe1440..980faf3d11f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -741,6 +741,14 @@ export const api = { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), }), + getFontPref: () => + fetchJSON("/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 { diff --git a/web/src/themes/context.tsx b/web/src/themes/context.tsx index 9f3161b41ee..3cf2d97b48b 100644 --- a/web/src/themes/context.tsx +++ b/web/src/themes/context.tsx @@ -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 >({}); + /** 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(() => { + 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( () => ({ theme: resolveTheme(themeName), themeName, availableThemes, setTheme, + fontId, + fontChoices: FONT_CHOICES, + setFont, }), - [themeName, availableThemes, setTheme, resolveTheme], + [themeName, availableThemes, setTheme, resolveTheme, fontId, setFont], ); return {children}; @@ -496,6 +594,9 @@ const ThemeContext = createContext({ 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; } diff --git a/web/src/themes/fonts.ts b/web/src/themes/fonts.ts new file mode 100644 index 00000000000..7ff648aa07e --- /dev/null +++ b/web/src/themes/fonts.ts @@ -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 ``, 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 = 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; +} diff --git a/web/src/themes/index.ts b/web/src/themes/index.ts index fa1b9e0f14c..08cf52f1f81 100644 --- a/web/src/themes/index.ts +++ b/web/src/themes/index.ts @@ -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"; diff --git a/website/docs/user-guide/features/extending-the-dashboard.md b/website/docs/user-guide/features/extending-the-dashboard.md index 0efbe8adb4c..79b84a73efb 100644 --- a/website/docs/user-guide/features/extending-the-dashboard.md +++ b/website/docs/user-guide/features/extending-the-dashboard.md @@ -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 ``, 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 | diff --git a/website/docs/user-guide/features/web-dashboard.md b/website/docs/user-guide/features/web-dashboard.md index ea2c627e371..7db4dce3aca 100644 --- a/website/docs/user-guide/features/web-dashboard.md +++ b/website/docs/user-guide/features/web-dashboard.md @@ -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 |