mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-13 14:02:16 +00:00
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.
160 lines
5.8 KiB
TypeScript
160 lines
5.8 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|