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
+119 -4
View File
@@ -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;