From 2444a87e674ce8ee84f075857e7b5f236a312d86 Mon Sep 17 00:00:00 2001
From: Bryan Li <15131870+btli@users.noreply.github.com>
Date: Mon, 20 Jul 2026 10:19:13 -0700
Subject: [PATCH 1/6] feat(web): load selected fonts via a curated catalog +
webfont loader
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Settings font controls only set a font NAME; nothing loaded the
family, so picking a Nerd Font or any non-installed IDE font silently
rendered the fallback stack. Add the loader half of the fix (the
dropdown is PR 2):
- fontCatalog.ts: a curated registry in three roles — sans (UI/chrome),
fixedWidth (mono UI), and code (editor/terminal) — each entry carrying
a stable id, label, CSS family, category, and delivery metadata
(bundled | google-css2 | self-hosted).
- webFontLoader.ts: a deduplicated loader that injects a keyless Google
CSS2 or explicit @font-face rules and awaits readiness via
document.fonts.load(). No-ops for bundled/system families; never
injects the same stylesheet twice; concurrent loads share one promise.
- Wire the loader into the preference-application path: applyUiFontFamily
and writeCodeFontFamily kick a catalog load; after the code font lands,
re-emit so Monaco re-measures (remeasureFonts) and xterm refits. Restore
on boot from main.tsx and embed.tsx.
Self-hosted fonts (Cascadia Code via Fontsource CDN, four Nerd Font Mono
variants via a pinned ryanoasis/nerd-fonts tag) are lazy-loaded on demand
— not eagerly imported — so the bundle stays lean.
The free-text inputs still work unchanged: a typed name that matches a
catalog family now loads; anything else is left to the OS as before.
Co-authored-by: omnigent
Signed-off-by: Bryan Li
---
web/src/embed.tsx | 17 ++
web/src/lib/codeFontPreferences.ts | 26 +++
web/src/lib/fontCatalog.test.ts | 112 ++++++++++
web/src/lib/fontCatalog.ts | 316 +++++++++++++++++++++++++++++
web/src/lib/uiFontPreferences.ts | 8 +
web/src/lib/webFontLoader.test.ts | 125 ++++++++++++
web/src/lib/webFontLoader.ts | 126 ++++++++++++
web/src/main.tsx | 9 +
web/src/shell/MonacoCodeEditor.tsx | 15 +-
9 files changed, 749 insertions(+), 5 deletions(-)
create mode 100644 web/src/lib/fontCatalog.test.ts
create mode 100644 web/src/lib/fontCatalog.ts
create mode 100644 web/src/lib/webFontLoader.test.ts
create mode 100644 web/src/lib/webFontLoader.ts
diff --git a/web/src/embed.tsx b/web/src/embed.tsx
index 1bb73a3bba..9f3c200fe4 100644
--- a/web/src/embed.tsx
+++ b/web/src/embed.tsx
@@ -45,6 +45,23 @@ import { initChatStore } from "./store/chatStore";
import "./index.css";
import { QueueFlushProvider } from "./hooks/QueueFlushProvider";
import { SessionUpdatesProvider } from "./hooks/SessionUpdatesProvider";
+import {
+ applyUiFontFamily,
+ applyUiFontScale,
+ readUiFontFamily,
+ readUiFontSizePx,
+} from "./lib/uiFontPreferences";
+import { loadCodeFontFamily, readCodeFontFamily } from "./lib/codeFontPreferences";
+
+// Restore the saved font preferences (and kick off any catalog webfont loads)
+// when the embed module loads. The UI/code font controls stay visible when
+// embedded (per-device readability prefs that don't conflict with host theming),
+// so a chosen font must be applied + fetched here just as standalone does in
+// main.tsx — otherwise the selection would only take effect on the next Settings
+// change. Guarded for SSR by the apply/load helpers.
+applyUiFontScale(readUiFontSizePx());
+applyUiFontFamily(readUiFontFamily());
+loadCodeFontFamily(readCodeFontFamily());
export type { OmnigentHostConfig } from "./lib/host";
export type { RoutingApi } from "./lib/routing";
diff --git a/web/src/lib/codeFontPreferences.ts b/web/src/lib/codeFontPreferences.ts
index 92c4a7c41c..e809f02db2 100644
--- a/web/src/lib/codeFontPreferences.ts
+++ b/web/src/lib/codeFontPreferences.ts
@@ -12,6 +12,8 @@
// (editor.updateOptions / term.options + refit) so a Settings change lands live
// without a reload or reconnect.
+import { loadFontByFamily } from "./webFontLoader";
+
const SIZE_STORAGE_KEY = "omnigent:code-font-size";
const FAMILY_STORAGE_KEY = "omnigent:code-font-family";
@@ -144,6 +146,30 @@ export function writeCodeFontFamily(name: string): void {
// Broadcast the intended value, not a storage re-read: a failed write must
// still live-apply the new family to mounted editors/terminals.
emit({ sizePx: readCodeFontSizePx(), family });
+ // If the family is a catalog font that needs fetching, load it and re-emit
+ // once its glyphs are ready. The first emit applies the family to the widgets
+ // immediately (fallback stack paints); the second, post-load emit makes them
+ // re-measure/refit against the real glyph cell so the editor/terminal pick up
+ // the newly-available face. No-ops for bundled/system/non-catalog families.
+ loadCodeFontFamily(family);
+}
+
+/**
+ * Load the webfont for a code family (if it's a catalog font that needs
+ * fetching) and re-emit the current code font once its glyphs are ready, so
+ * mounted Monaco/xterm widgets re-measure against the real cell metrics. A
+ * bundled/system/non-catalog family, or an SSR context, is a no-op. Exposed for
+ * boot-time restore in main.tsx / embed.tsx.
+ */
+export function loadCodeFontFamily(family: string): void {
+ const normalized = normalizeCodeFontFamily(family);
+ const { entry, ready } = loadFontByFamily(normalized);
+ if (!entry) return;
+ void ready.then(() => {
+ // Re-read live prefs at resolution time: if the user changed the family
+ // again while this was loading, don't clobber the newer choice.
+ emit(readCodeFont());
+ });
}
/**
diff --git a/web/src/lib/fontCatalog.test.ts b/web/src/lib/fontCatalog.test.ts
new file mode 100644
index 0000000000..e5677b304b
--- /dev/null
+++ b/web/src/lib/fontCatalog.test.ts
@@ -0,0 +1,112 @@
+import { describe, expect, it } from "vitest";
+import {
+ FONT_CATALOG,
+ FONT_CATALOG_BY_CATEGORY,
+ type FontCategory,
+ getFontByFamily,
+ getFontById,
+} from "./fontCatalog";
+
+const CATEGORIES: FontCategory[] = ["sans", "fixedWidth", "code"];
+
+describe("fontCatalog — integrity", () => {
+ it("has unique ids across the whole catalog", () => {
+ const ids = FONT_CATALOG.map((e) => e.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it("groups every entry under exactly its declared category", () => {
+ for (const category of CATEGORIES) {
+ for (const entry of FONT_CATALOG_BY_CATEGORY[category]) {
+ expect(entry.category).toBe(category);
+ }
+ }
+ });
+
+ it("the by-category groups partition the flat catalog", () => {
+ const grouped = CATEGORIES.flatMap((c) => FONT_CATALOG_BY_CATEGORY[c]);
+ expect(grouped.length).toBe(FONT_CATALOG.length);
+ expect(new Set(grouped)).toEqual(new Set(FONT_CATALOG));
+ });
+
+ it("populates each category the interface uses", () => {
+ for (const category of CATEGORIES) {
+ expect(FONT_CATALOG_BY_CATEGORY[category].length).toBeGreaterThan(0);
+ }
+ });
+
+ it("carries valid source metadata for every entry", () => {
+ for (const entry of FONT_CATALOG) {
+ if (entry.source === "google-css2") {
+ // A CSS2 entry must have a fetchable stylesheet href.
+ expect(entry.cssUrl).toMatch(/^https:\/\/fonts\.googleapis\.com\/css2\?/);
+ expect(entry.faces).toBeUndefined();
+ } else if (entry.source === "self-hosted") {
+ // A self-hosted entry must carry at least one @font-face with an https URL.
+ expect(entry.faces?.length).toBeGreaterThan(0);
+ for (const face of entry.faces ?? []) {
+ expect(face.url).toMatch(/^https:\/\//);
+ }
+ expect(entry.cssUrl).toBeUndefined();
+ } else {
+ // Bundled: nothing to fetch.
+ expect(entry.cssUrl).toBeUndefined();
+ expect(entry.faces).toBeUndefined();
+ }
+ }
+ });
+
+ it("includes the bundled Geist Mono and a system default with no fetch", () => {
+ const geist = getFontById("geist-mono");
+ expect(geist?.source).toBe("bundled");
+ expect(geist?.family).toBe("Geist Mono Variable");
+
+ const system = getFontById("system-ui");
+ expect(system?.source).toBe("bundled");
+ // Empty family = "System default" (maps to --font-sans, nothing to load).
+ expect(system?.family).toBe("");
+ });
+
+ it("includes the expected common families and Nerd Font variants", () => {
+ const labels = new Set(FONT_CATALOG.map((e) => e.label));
+ for (const expected of [
+ "Inter",
+ "Roboto",
+ "JetBrains Mono",
+ "Fira Code",
+ "Cascadia Code",
+ "JetBrainsMono Nerd Font Mono",
+ "CaskaydiaCove Nerd Font Mono",
+ ]) {
+ expect(labels).toContain(expected);
+ }
+ });
+});
+
+describe("fontCatalog — lookups", () => {
+ it("resolves an entry by id", () => {
+ expect(getFontById("inter")?.family).toBe("Inter");
+ expect(getFontById("nope")).toBeUndefined();
+ });
+
+ it("resolves a typed family name case-insensitively", () => {
+ expect(getFontByFamily("Fira Code")?.id).toBe("fira-code");
+ expect(getFontByFamily("fira code")?.id).toBe("fira-code");
+ expect(getFontByFamily(" FIRA CODE ")?.id).toBe("fira-code");
+ });
+
+ it("returns undefined for an empty name or a non-catalog family", () => {
+ expect(getFontByFamily("")).toBeUndefined();
+ expect(getFontByFamily(" ")).toBeUndefined();
+ // A locally-installed font the catalog doesn't know is left to the OS.
+ expect(getFontByFamily("Comic Sans MS")).toBeUndefined();
+ });
+
+ it("resolves a family shared across categories to a single entry", () => {
+ // IBM Plex Mono is offered in both fixedWidth and code; family lookup must
+ // still resolve deterministically (first catalog occurrence wins).
+ const entry = getFontByFamily("IBM Plex Mono");
+ expect(entry).toBeDefined();
+ expect(entry?.family).toBe("IBM Plex Mono");
+ });
+});
diff --git a/web/src/lib/fontCatalog.ts b/web/src/lib/fontCatalog.ts
new file mode 100644
index 0000000000..3e9f9830ab
--- /dev/null
+++ b/web/src/lib/fontCatalog.ts
@@ -0,0 +1,316 @@
+// Curated registry of fonts the app can load on demand.
+//
+// The Settings font controls only set a font NAME; nothing loads the font, so
+// picking a family the OS doesn't have installed renders the fallback stack
+// instead. This catalog pairs each offered family with the metadata a loader
+// (see lib/webFontLoader.ts) needs to actually fetch it, so a selected family
+// works without a local install.
+//
+// Fonts are grouped into three roles the interface uses distinctly:
+// - `sans` — UI/chrome text (the --ui-font-family variable).
+// - `fixedWidth` — general monospace UI usage.
+// - `code` — the code editor (Monaco) and terminal (xterm).
+// PR 2's Settings dropdowns render one control per category off this shape.
+
+/** The three distinct font roles the interface exposes. */
+export type FontCategory = "sans" | "fixedWidth" | "code";
+
+/**
+ * How a catalog entry's face data is delivered:
+ * - `bundled` — already shipped in the app bundle (Fontsource import in
+ * index.css); no network fetch, the loader no-ops.
+ * - `google-css2` — a keyless Google Fonts CSS2 stylesheet (``); Google
+ * serves the right woff2 per browser. Used for common web families.
+ * - `self-hosted` — explicit `@font-face` rules the loader injects, pointing
+ * at a CDN/asset woff2/ttf. Used for Cascadia Code + the Nerd Font variants,
+ * which aren't on Google Fonts (or whose glyph coverage we pin explicitly).
+ */
+export type FontSource = "bundled" | "google-css2" | "self-hosted";
+
+/** A single `@font-face` the self-hosted loader path injects. */
+export interface FontFaceAsset {
+ /** Absolute URL to the font file (woff2/woff/ttf). */
+ readonly url: string;
+ /** CSS `font-weight` for this face, e.g. `"400"` or `"400 700"`. */
+ readonly weight?: string;
+ /** CSS `font-style` for this face; defaults to `"normal"`. */
+ readonly style?: string;
+ /** `format(...)` hint, e.g. `"woff2"` or `"truetype"`. */
+ readonly format?: string;
+}
+
+/** One offered font, with everything needed to display AND load it. */
+export interface FontCatalogEntry {
+ /** Stable, URL/attribute-safe id. Never reused or renamed. */
+ readonly id: string;
+ /** Human-facing label for the Settings dropdown, e.g. `"JetBrains Mono"`. */
+ readonly label: string;
+ /**
+ * The CSS `font-family` name to apply and to await via `document.fonts.load`.
+ * Must match the family name the delivered face registers under.
+ */
+ readonly family: string;
+ /** Which of the three interface roles this font is offered for. */
+ readonly category: FontCategory;
+ /** How the face data is delivered (see {@link FontSource}). */
+ readonly source: FontSource;
+ /**
+ * `google-css2`: the stylesheet href to inject.
+ * Ignored for other sources.
+ */
+ readonly cssUrl?: string;
+ /**
+ * `self-hosted`: the `@font-face` faces to inject.
+ * Ignored for other sources.
+ */
+ readonly faces?: readonly FontFaceAsset[];
+}
+
+// Pinned Nerd Fonts release — a tag, never `@master`, so the patched-font paths
+// and glyph coverage stay stable across deploys.
+const NERD_FONTS_TAG = "v3.4.0";
+const NERD_FONTS_BASE = `https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@${NERD_FONTS_TAG}/patched-fonts`;
+
+// Fontsource CDN version for Cascadia Code (not on Google Fonts). Pinned so the
+// woff2 URLs don't drift.
+const CASCADIA_FONTSOURCE = "https://cdn.jsdelivr.net/fontsource/fonts/cascadia-code@5.2.3";
+
+/**
+ * Build a keyless Google Fonts CSS2 href for `family` at the given weights.
+ * Spaces become `+`; the `:wght@…;…` axis spec uses the literal `:@;` Google's
+ * CSS2 endpoint expects (all URL-safe here — no user input reaches this).
+ */
+function googleCss2Url(family: string, weights: readonly number[]): string {
+ const spec = `${family.replace(/ /g, "+")}:wght@${weights.join(";")}`;
+ return `https://fonts.googleapis.com/css2?family=${spec}&display=swap`;
+}
+
+/** A single-file Nerd Font Mono face (regular weight) from the pinned CDN. */
+function nerdFace(path: string): readonly FontFaceAsset[] {
+ return [{ url: `${NERD_FONTS_BASE}/${path}`, weight: "400", format: "truetype" }];
+}
+
+// ---- Sans (UI/chrome) -----------------------------------------------------
+
+const SANS_FONTS: readonly FontCatalogEntry[] = [
+ // System default: no face to load — the empty family maps to --font-sans.
+ { id: "system-ui", label: "System default", family: "", category: "sans", source: "bundled" },
+ {
+ id: "inter",
+ label: "Inter",
+ family: "Inter",
+ category: "sans",
+ source: "google-css2",
+ cssUrl: googleCss2Url("Inter", [400, 500, 600, 700]),
+ },
+ {
+ id: "roboto",
+ label: "Roboto",
+ family: "Roboto",
+ category: "sans",
+ source: "google-css2",
+ cssUrl: googleCss2Url("Roboto", [400, 500, 700]),
+ },
+ {
+ id: "open-sans",
+ label: "Open Sans",
+ family: "Open Sans",
+ category: "sans",
+ source: "google-css2",
+ cssUrl: googleCss2Url("Open Sans", [400, 600, 700]),
+ },
+ {
+ id: "lato",
+ label: "Lato",
+ family: "Lato",
+ category: "sans",
+ source: "google-css2",
+ cssUrl: googleCss2Url("Lato", [400, 700]),
+ },
+ {
+ id: "source-sans-3",
+ label: "Source Sans 3",
+ family: "Source Sans 3",
+ category: "sans",
+ source: "google-css2",
+ cssUrl: googleCss2Url("Source Sans 3", [400, 600, 700]),
+ },
+ {
+ id: "geist",
+ label: "Geist",
+ family: "Geist",
+ category: "sans",
+ source: "google-css2",
+ cssUrl: googleCss2Url("Geist", [400, 500, 600, 700]),
+ },
+];
+
+// ---- Fixed width (general monospace UI) -----------------------------------
+
+const FIXED_WIDTH_FONTS: readonly FontCatalogEntry[] = [
+ // Bundled via Fontsource (@fontsource-variable/geist-mono in index.css) — the
+ // loader no-ops for this one.
+ {
+ id: "geist-mono",
+ label: "Geist Mono",
+ family: "Geist Mono Variable",
+ category: "fixedWidth",
+ source: "bundled",
+ },
+ {
+ id: "ibm-plex-mono",
+ label: "IBM Plex Mono",
+ family: "IBM Plex Mono",
+ category: "fixedWidth",
+ source: "google-css2",
+ cssUrl: googleCss2Url("IBM Plex Mono", [400, 500, 600, 700]),
+ },
+ {
+ id: "roboto-mono",
+ label: "Roboto Mono",
+ family: "Roboto Mono",
+ category: "fixedWidth",
+ source: "google-css2",
+ cssUrl: googleCss2Url("Roboto Mono", [400, 500, 700]),
+ },
+ {
+ id: "space-mono",
+ label: "Space Mono",
+ family: "Space Mono",
+ category: "fixedWidth",
+ source: "google-css2",
+ cssUrl: googleCss2Url("Space Mono", [400, 700]),
+ },
+];
+
+// ---- Code (editor + terminal) ---------------------------------------------
+
+const CODE_FONTS: readonly FontCatalogEntry[] = [
+ {
+ id: "jetbrains-mono",
+ label: "JetBrains Mono",
+ family: "JetBrains Mono",
+ category: "code",
+ source: "google-css2",
+ cssUrl: googleCss2Url("JetBrains Mono", [400, 500, 700]),
+ },
+ {
+ id: "fira-code",
+ label: "Fira Code",
+ family: "Fira Code",
+ category: "code",
+ source: "google-css2",
+ cssUrl: googleCss2Url("Fira Code", [400, 500, 700]),
+ },
+ {
+ id: "source-code-pro",
+ label: "Source Code Pro",
+ family: "Source Code Pro",
+ category: "code",
+ source: "google-css2",
+ cssUrl: googleCss2Url("Source Code Pro", [400, 500, 700]),
+ },
+ {
+ id: "ibm-plex-mono-code",
+ label: "IBM Plex Mono",
+ family: "IBM Plex Mono",
+ category: "code",
+ source: "google-css2",
+ cssUrl: googleCss2Url("IBM Plex Mono", [400, 500, 600, 700]),
+ },
+ // Cascadia Code isn't a Google Fonts family we bundle; deliver its @font-face
+ // from the Fontsource CDN (latin subset, regular + bold).
+ {
+ id: "cascadia-code",
+ label: "Cascadia Code",
+ family: "Cascadia Code",
+ category: "code",
+ source: "self-hosted",
+ faces: [
+ { url: `${CASCADIA_FONTSOURCE}/latin-400-normal.woff2`, weight: "400", format: "woff2" },
+ { url: `${CASCADIA_FONTSOURCE}/latin-700-normal.woff2`, weight: "700", format: "woff2" },
+ ],
+ },
+ // Nerd Font variants: patched glyphs (powerline, icons) IDE/terminal users
+ // expect. Delivered as single-file Mono faces from the pinned Nerd Fonts CDN,
+ // lazily — never eagerly imported.
+ {
+ id: "jetbrainsmono-nerd-font-mono",
+ label: "JetBrainsMono Nerd Font Mono",
+ family: "JetBrainsMono Nerd Font Mono",
+ category: "code",
+ source: "self-hosted",
+ faces: nerdFace("JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFontMono-Regular.ttf"),
+ },
+ {
+ id: "firacode-nerd-font-mono",
+ label: "FiraCode Nerd Font Mono",
+ family: "FiraCode Nerd Font Mono",
+ category: "code",
+ source: "self-hosted",
+ faces: nerdFace("FiraCode/Regular/FiraCodeNerdFontMono-Regular.ttf"),
+ },
+ {
+ id: "saucecodepro-nerd-font-mono",
+ label: "SauceCodePro Nerd Font Mono",
+ family: "SauceCodePro Nerd Font Mono",
+ category: "code",
+ source: "self-hosted",
+ faces: nerdFace("SourceCodePro/SauceCodeProNerdFontMono-Regular.ttf"),
+ },
+ {
+ id: "caskaydiacove-nerd-font-mono",
+ label: "CaskaydiaCove Nerd Font Mono",
+ family: "CaskaydiaCove Nerd Font Mono",
+ category: "code",
+ source: "self-hosted",
+ faces: nerdFace("CascadiaCode/CaskaydiaCoveNerdFontMono-Regular.ttf"),
+ },
+];
+
+/** Every catalog entry, in category then display order. */
+export const FONT_CATALOG: readonly FontCatalogEntry[] = [
+ ...SANS_FONTS,
+ ...FIXED_WIDTH_FONTS,
+ ...CODE_FONTS,
+];
+
+/** The catalog grouped by role, for the three Settings controls. */
+export const FONT_CATALOG_BY_CATEGORY: Readonly> =
+ {
+ sans: SANS_FONTS,
+ fixedWidth: FIXED_WIDTH_FONTS,
+ code: CODE_FONTS,
+ };
+
+// id → entry, built once. Ids are unique across the catalog (asserted in tests).
+const BY_ID = new Map(FONT_CATALOG.map((e) => [e.id, e]));
+
+/** Look up a catalog entry by its stable id. */
+export function getFontById(id: string): FontCatalogEntry | undefined {
+ return BY_ID.get(id);
+}
+
+// Lower-cased family → entry. A family (e.g. "IBM Plex Mono") can appear in more
+// than one category; the FIRST catalog occurrence wins, which is enough for the
+// loader — it only needs the delivery metadata, identical across duplicates.
+const BY_FAMILY = new Map();
+for (const entry of FONT_CATALOG) {
+ const key = entry.family.trim().toLowerCase();
+ if (key && !BY_FAMILY.has(key)) BY_FAMILY.set(key, entry);
+}
+
+/**
+ * Resolve a typed/stored family NAME to its catalog entry, case-insensitively.
+ *
+ * This is the bridge from the existing free-text font inputs (which store a
+ * bare family string) to the loader: a typed name that matches a catalog family
+ * gets loaded; anything else (a locally-installed font, a partial name) resolves
+ * to `undefined` and the loader leaves it to the OS, unchanged. An empty name
+ * (System default) is never a catalog family, so it also returns `undefined`.
+ */
+export function getFontByFamily(family: string): FontCatalogEntry | undefined {
+ const key = family.trim().toLowerCase();
+ if (!key) return undefined;
+ return BY_FAMILY.get(key);
+}
diff --git a/web/src/lib/uiFontPreferences.ts b/web/src/lib/uiFontPreferences.ts
index c1c9b32fad..d333cc8b75 100644
--- a/web/src/lib/uiFontPreferences.ts
+++ b/web/src/lib/uiFontPreferences.ts
@@ -16,6 +16,8 @@
// `var(--ui-font-family, var(--font-sans))`, so an unset family falls back to
// the system stack and any value we set on documentElement wins.
+import { loadFontByFamily } from "./webFontLoader";
+
const STORAGE_KEY = "omnigent:ui-font-size";
/** Reference size that a scale of 1 corresponds to (Tailwind/browser default). */
@@ -166,5 +168,11 @@ export function applyUiFontFamily(name: string): void {
document.documentElement.style.removeProperty("--ui-font-family");
return;
}
+ // Kick a webfont load when the name matches a catalog family so the glyphs
+ // actually arrive (fire-and-forget: the CSS var is set now and font-display:
+ // swap paints the face once it lands). A non-catalog name is left to the OS —
+ // the existing free-text behavior. The `, var(--font-sans)` fallback covers
+ // the gap before load and any name that never resolves.
+ void loadFontByFamily(normalized);
document.documentElement.style.setProperty("--ui-font-family", `${normalized}, var(--font-sans)`);
}
diff --git a/web/src/lib/webFontLoader.test.ts b/web/src/lib/webFontLoader.test.ts
new file mode 100644
index 0000000000..bce19fcada
--- /dev/null
+++ b/web/src/lib/webFontLoader.test.ts
@@ -0,0 +1,125 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { type FontCatalogEntry, getFontById } from "./fontCatalog";
+import { resetFontLoaderForTests, loadFont, loadFontByFamily } from "./webFontLoader";
+
+// jsdom has no FontFaceSet; stub document.fonts.load so readiness resolves and
+// we can count how often a given family was requested.
+let loadCalls: string[] = [];
+
+beforeEach(() => {
+ loadCalls = [];
+ Object.defineProperty(document, "fonts", {
+ configurable: true,
+ value: {
+ load: vi.fn((spec: string) => {
+ loadCalls.push(spec);
+ return Promise.resolve([]);
+ }),
+ },
+ });
+});
+
+afterEach(() => {
+ resetFontLoaderForTests();
+ for (const node of document.querySelectorAll("[data-omnigent-font]")) node.remove();
+ vi.restoreAllMocks();
+});
+
+function styleNodes(id: string): number {
+ return document.querySelectorAll(`[data-omnigent-font="${id}"]`).length;
+}
+
+describe("webFontLoader — google-css2", () => {
+ it("injects a stylesheet link once and awaits readiness", async () => {
+ const inter = getFontById("inter") as FontCatalogEntry;
+ await loadFont(inter);
+
+ const link = document.querySelector(`link[data-omnigent-font="inter"]`);
+ expect(link).not.toBeNull();
+ expect(link?.rel).toBe("stylesheet");
+ expect(link?.href).toBe(inter.cssUrl);
+ // Readiness awaited via document.fonts.load('16px ""').
+ expect(loadCalls).toContain(`16px "Inter"`);
+ });
+
+ it("never injects the same stylesheet twice", async () => {
+ const inter = getFontById("inter") as FontCatalogEntry;
+ await loadFont(inter);
+ await loadFont(inter);
+ expect(styleNodes("inter")).toBe(1);
+ });
+
+ it("shares one in-flight promise for concurrent loads", async () => {
+ const roboto = getFontById("roboto") as FontCatalogEntry;
+ const a = loadFont(roboto);
+ const b = loadFont(roboto);
+ expect(a).toBe(b);
+ await Promise.all([a, b]);
+ expect(styleNodes("roboto")).toBe(1);
+ // A single readiness await, not one per call.
+ expect(loadCalls.filter((s) => s === `16px "Roboto"`).length).toBe(1);
+ });
+});
+
+describe("webFontLoader — self-hosted", () => {
+ it("injects @font-face rules once for a Nerd Font", async () => {
+ const nerd = getFontById("jetbrainsmono-nerd-font-mono") as FontCatalogEntry;
+ await loadFont(nerd);
+
+ const style = document.querySelector(
+ `style[data-omnigent-font="jetbrainsmono-nerd-font-mono"]`,
+ );
+ expect(style).not.toBeNull();
+ expect(style?.textContent).toContain("@font-face");
+ expect(style?.textContent).toContain(`font-family: 'JetBrainsMono Nerd Font Mono'`);
+ expect(style?.textContent).toContain(nerd.faces?.[0].url ?? "MISSING");
+ expect(loadCalls).toContain(`16px "JetBrainsMono Nerd Font Mono"`);
+ });
+
+ it("injects multiple faces for a multi-weight self-hosted font", async () => {
+ const cascadia = getFontById("cascadia-code") as FontCatalogEntry;
+ await loadFont(cascadia);
+ const style = document.querySelector(`style[data-omnigent-font="cascadia-code"]`);
+ const faceCount = style?.textContent?.match(/@font-face/g)?.length ?? 0;
+ expect(faceCount).toBe(cascadia.faces?.length);
+ });
+});
+
+describe("webFontLoader — no-ops", () => {
+ it("does not fetch a bundled font", async () => {
+ const geist = getFontById("geist-mono") as FontCatalogEntry;
+ await loadFont(geist);
+ expect(styleNodes("geist-mono")).toBe(0);
+ expect(loadCalls.length).toBe(0);
+ });
+
+ it("does not fetch the empty system-default family", async () => {
+ const system = getFontById("system-ui") as FontCatalogEntry;
+ await loadFont(system);
+ expect(loadCalls.length).toBe(0);
+ });
+});
+
+describe("webFontLoader — loadFontByFamily bridge", () => {
+ it("loads a catalog family typed as a bare name", async () => {
+ const { entry, ready } = loadFontByFamily("Fira Code");
+ expect(entry?.id).toBe("fira-code");
+ await ready;
+ expect(styleNodes("fira-code")).toBe(1);
+ expect(loadCalls).toContain(`16px "Fira Code"`);
+ });
+
+ it("resolves without loading for a non-catalog family", async () => {
+ const { entry, ready } = loadFontByFamily("Comic Sans MS");
+ expect(entry).toBeUndefined();
+ await ready;
+ expect(loadCalls.length).toBe(0);
+ });
+
+ it("resolves without loading for an empty family", async () => {
+ const { entry, ready } = loadFontByFamily("");
+ expect(entry).toBeUndefined();
+ await ready;
+ expect(loadCalls.length).toBe(0);
+ });
+});
diff --git a/web/src/lib/webFontLoader.ts b/web/src/lib/webFontLoader.ts
new file mode 100644
index 0000000000..dd6c7c487c
--- /dev/null
+++ b/web/src/lib/webFontLoader.ts
@@ -0,0 +1,126 @@
+// On-demand web font loader.
+//
+// Given a catalog entry (see lib/fontCatalog.ts), inject the stylesheet or
+// `@font-face` rules that fetch its face data, then await readiness so callers
+// know when glyphs are actually available (the moment to re-measure Monaco /
+// refit xterm). Deduplicated on every axis:
+// - `bundled` fonts and empty families no-op (nothing to fetch).
+// - each stylesheet/asset is injected AT MOST ONCE, keyed by entry id.
+// - concurrent loads of the same font share one in-flight promise.
+//
+// SSR/no-DOM safe: with no `document`, load() resolves immediately (there's
+// nothing to paint), so boot-time restore on the server is a harmless no-op.
+
+import { type FontCatalogEntry, getFontByFamily } from "./fontCatalog";
+
+// Marker so injected nodes are recognizable in the DOM (and idempotent across
+// a hot reload that re-runs this module with a fresh Map).
+const DATA_ATTR = "data-omnigent-font";
+
+// entry.id → the load promise. Present = injection started; resolved = the
+// font's `document.fonts.load()` settled (or timed out / errored — see below).
+const loads = new Map>();
+
+/**
+ * Await the browser actually having `family` ready to paint.
+ *
+ * `document.fonts.load('16px "Family"')` kicks the fetch for any matching
+ * unloaded `@font-face` and resolves when they finish. A short timeout guards a
+ * face that never resolves (offline, blocked CDN) so a caller awaiting readiness
+ * isn't wedged — the CSS fallback stack renders meanwhile, and the glyphs swap
+ * in if the fetch lands later (`font-display: swap`).
+ */
+async function awaitFontReady(family: string): Promise {
+ const fonts = document.fonts;
+ if (!fonts?.load) return;
+ const spec = `16px "${family}"`;
+ const timeout = new Promise((resolve) => window.setTimeout(resolve, 8000));
+ try {
+ await Promise.race([fonts.load(spec).then(() => undefined), timeout]);
+ } catch {
+ // A rejected load (e.g. malformed descriptor) must not reject the caller;
+ // the fallback stack still renders.
+ }
+}
+
+/** Inject a `` for a Google CSS2 (or any CSS) href. */
+function injectStylesheet(entry: FontCatalogEntry): void {
+ if (!entry.cssUrl) return;
+ // Guard against a duplicate node if this module's Map was reset (hot reload).
+ if (document.querySelector(`link[${DATA_ATTR}="${entry.id}"]`)) return;
+ const link = document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = entry.cssUrl;
+ link.setAttribute(DATA_ATTR, entry.id);
+ document.head.appendChild(link);
+}
+
+/** Inject a `