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 `