diff --git a/web/src/components/FontFamilyCombobox.test.tsx b/web/src/components/FontFamilyCombobox.test.tsx new file mode 100644 index 0000000000..8c69e77f40 --- /dev/null +++ b/web/src/components/FontFamilyCombobox.test.tsx @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { FontFamilyCombobox } from "./FontFamilyCombobox"; +import { FONT_CATALOG_BY_CATEGORY } from "@/lib/fontCatalog"; +import { resetFontLoaderForTests } from "@/lib/webFontLoader"; + +// jsdom has no FontFaceSet; stub document.fonts.load so the loader (invoked when +// the dropdown opens to render previews) doesn't throw. +beforeEach(() => { + Object.defineProperty(document, "fonts", { + configurable: true, + value: { load: vi.fn(() => Promise.resolve([])) }, + }); +}); + +afterEach(() => { + cleanup(); + // The loader dedups by resource across calls; clear it so each test's open + // re-injects rather than hitting a cached promise. + resetFontLoaderForTests(); + for (const node of document.querySelectorAll("[data-omnigent-font]")) node.remove(); + vi.restoreAllMocks(); +}); + +function renderCombobox(props: Partial[0]> = {}) { + const onChange = vi.fn(); + render( + , + ); + return { onChange }; +} + +describe("FontFamilyCombobox", () => { + it("renders every font in its category (plus the default row)", () => { + renderCombobox({ category: "sans" }); + fireEvent.click(screen.getByTestId("test-font-trigger")); + + expect(screen.getByTestId("test-font-option-default")).toBeInTheDocument(); + for (const entry of FONT_CATALOG_BY_CATEGORY.sans) { + if (!entry.family) continue; // the empty "system default" entry is the default row + expect(screen.getByTestId(`test-font-option-${entry.family}`)).toBeInTheDocument(); + } + // A code-only font must NOT leak into the sans list. + expect(screen.queryByTestId("test-font-option-JetBrains Mono")).toBeNull(); + }); + + it("calls onChange with the selected catalog family and loads its webfont", () => { + const { onChange } = renderCombobox({ category: "sans" }); + fireEvent.click(screen.getByTestId("test-font-trigger")); + // Opening the dropdown eagerly kicks the loader for each catalog face so + // its preview renders in-face — the google-css2 path injects a stylesheet. + expect(document.querySelector(`link[data-omnigent-font]`)).not.toBeNull(); + + fireEvent.click(screen.getByTestId("test-font-option-Inter")); + expect(onChange).toHaveBeenCalledWith("Inter"); + }); + + it("calls onChange with an empty string for the default row", () => { + const { onChange } = renderCombobox({ category: "sans", value: "Inter" }); + fireEvent.click(screen.getByTestId("test-font-trigger")); + fireEvent.click(screen.getByTestId("test-font-option-default")); + expect(onChange).toHaveBeenCalledWith(""); + }); + + it("surfaces a stored non-catalog family as a selectable custom row", () => { + const { onChange } = renderCombobox({ category: "sans", value: "My Local Font" }); + expect(screen.getByTestId("test-font-trigger")).toHaveTextContent("My Local Font"); + + fireEvent.click(screen.getByTestId("test-font-trigger")); + const custom = screen.getByTestId("test-font-option-custom-current"); + expect(custom).toHaveTextContent("My Local Font"); + fireEvent.click(custom); + expect(onChange).toHaveBeenCalledWith("My Local Font"); + }); + + it("shows a cross-category catalog family as a custom row for this category", () => { + // "Fira Code" is a code-catalog family, absent from the sans list. Stored in + // the sans slot it must render as Custom — not be silently swallowed by a + // cross-category catalog match — so the user can see and keep their entry. + const { onChange } = renderCombobox({ category: "sans", value: "Fira Code" }); + expect(screen.getByTestId("test-font-trigger")).toHaveTextContent("Fira Code"); + + fireEvent.click(screen.getByTestId("test-font-trigger")); + // It is NOT offered as a normal sans option… + expect(screen.queryByTestId("test-font-option-Fira Code")).toBeNull(); + // …it is the custom row instead. + const custom = screen.getByTestId("test-font-option-custom-current"); + expect(custom).toHaveTextContent("Fira Code"); + expect(custom).toHaveTextContent("Custom"); + fireEvent.click(custom); + expect(onChange).toHaveBeenCalledWith("Fira Code"); + }); + + it("treats a same-category catalog family as a known option, not custom", () => { + // The mirror of the cross-category case: a fixedWidth family stored in the + // fixedWidth slot is a normal option, so no custom row appears for it. + renderCombobox({ category: "fixedWidth", value: "IBM Plex Mono" }); + fireEvent.click(screen.getByTestId("test-font-trigger")); + expect(screen.getByTestId("test-font-option-IBM Plex Mono")).toBeInTheDocument(); + expect(screen.queryByTestId("test-font-option-custom-current")).toBeNull(); + }); + + it("applies a typed family the catalog doesn't list (free-text escape hatch)", () => { + const { onChange } = renderCombobox({ category: "code" }); + fireEvent.click(screen.getByTestId("test-font-trigger")); + fireEvent.change(screen.getByTestId("test-font-input"), { target: { value: "Menlo" } }); + fireEvent.click(screen.getByTestId("test-font-option-custom")); + expect(onChange).toHaveBeenCalledWith("Menlo"); + }); +}); diff --git a/web/src/components/FontFamilyCombobox.tsx b/web/src/components/FontFamilyCombobox.tsx new file mode 100644 index 0000000000..a67b9a2cc8 --- /dev/null +++ b/web/src/components/FontFamilyCombobox.tsx @@ -0,0 +1,222 @@ +// Searchable font picker for the Settings → Appearance font controls. +// +// Replaces the old free-text font inputs: lists a font-catalog category (see +// lib/fontCatalog.ts) by label, each option previewed in its own face, with a +// "Default" row and a free-text escape hatch so a custom family the catalog +// doesn't know is still honored. Selecting a catalog option triggers the +// webfont loader (via the caller's preference-apply, and eagerly on open here so +// previews render) so the font actually loads without a local install. + +import { useEffect, useMemo, useState } from "react"; +import { ChevronsUpDownIcon } from "lucide-react"; + +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { type FontCategory, FONT_CATALOG_BY_CATEGORY } from "@/lib/fontCatalog"; +import { loadFontByFamily } from "@/lib/webFontLoader"; +import { cn } from "@/lib/utils"; + +interface FontFamilyComboboxProps { + /** Which catalog category to list. */ + category: FontCategory; + /** Current family; "" means the default (no override). */ + value: string; + /** Fired with the chosen family ("" for default). */ + onChange: (family: string) => void; + /** Label for the "no override" row, e.g. "System default" / "Editor default". */ + defaultLabel: string; + /** Accessible name for the trigger + search. */ + ariaLabel: string; + /** Base for the control's data-testids (`-trigger`, `-input`, …). */ + testId: string; + /** Fallback CSS stack appended to a preview so an unloaded face degrades. */ + previewFallback: string; +} + +/** A resolved option row: the family to apply and how to display it. */ +interface FontOption { + /** Family to persist/apply; "" = default. */ + family: string; + /** Human label shown in the trigger and list. */ + label: string; +} + +export function FontFamilyCombobox({ + category, + value, + onChange, + defaultLabel, + ariaLabel, + testId, + previewFallback, +}: FontFamilyComboboxProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + + // Catalog entries for this role, deduped by family (a family can appear more + // than once — e.g. IBM Plex Mono in fixedWidth and code). The empty-family + // "system default" catalog entry is dropped: the Default row below covers it. + const options = useMemo(() => { + const seen = new Set(); + const rows: FontOption[] = []; + for (const entry of FONT_CATALOG_BY_CATEGORY[category]) { + if (!entry.family) continue; + const key = entry.family.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + rows.push({ family: entry.family, label: entry.label }); + } + return rows; + }, [category]); + + // A stored family this category's dropdown doesn't offer (a locally-installed + // or previously typed font, or a family that only lives in a DIFFERENT catalog + // category). Surface it as its own selectable row so the current choice is + // visible and re-selectable — the backward-compat escape hatch. Membership is + // decided against THIS category's options only: a cross-category catalog match + // (e.g. a sans slot holding "Fira Code", a code-only family) must still show + // as Custom, so we don't use getFontByFamily's cross-category fallback here. + const customOption = useMemo(() => { + if (!value) return null; + const key = value.toLowerCase(); + if (options.some((option) => option.family.toLowerCase() === key)) return null; + return { family: value, label: value }; + }, [value, options]); + + // On open, eagerly load every listed catalog face so its preview renders in + // its own font rather than the fallback. Fire-and-forget; non-catalog and + // bundled entries no-op inside the loader. + useEffect(() => { + if (!open) return; + for (const option of options) loadFontByFamily(option.family, category); + }, [open, options, category]); + + const selectedLabel = value + ? (customOption?.label ?? + options.find((option) => option.family.toLowerCase() === value.toLowerCase())?.label ?? + value) + : defaultLabel; + + const select = (family: string) => { + onChange(family); + setOpen(false); + setSearch(""); + }; + + // Let the user apply a typed family the list doesn't contain (custom fallback). + const trimmedSearch = search.trim(); + const hasExactMatch = options.some( + (option) => option.label.toLowerCase() === trimmedSearch.toLowerCase(), + ); + const showCustomEntry = trimmedSearch.length > 0 && !hasExactMatch; + + const previewStyle = (family: string) => ({ fontFamily: `"${family}", ${previewFallback}` }); + + return ( + { + setOpen(next); + if (!next) setSearch(""); + }} + > + + + + + + itemValue.toLowerCase().includes(query.trim().toLowerCase()) ? 1 : 0 + } + > + + + No fonts found. + + {/* Default (no override). */} + select("")} + > + {defaultLabel} + + + {/* An out-of-catalog stored family, so the current custom value shows. */} + {customOption && ( + select(customOption.family)} + > + + {customOption.label} + + Custom + + )} + + {options.map((option) => ( + select(option.family)} + > + + {option.label} + + + ))} + + {/* Free-text escape hatch: apply whatever family was typed. */} + {showCustomEntry && ( + select(trimmedSearch)} + > + + Use “{trimmedSearch}” + + + )} + + + + + + ); +} diff --git a/web/src/embed.tsx b/web/src/embed.tsx index 1bb73a3bba..06a6db765b 100644 --- a/web/src/embed.tsx +++ b/web/src/embed.tsx @@ -45,6 +45,15 @@ import { initChatStore } from "./store/chatStore"; import "./index.css"; import { QueueFlushProvider } from "./hooks/QueueFlushProvider"; import { SessionUpdatesProvider } from "./hooks/SessionUpdatesProvider"; +import { restoreFontPreferences } from "./lib/restoreFontPreferences"; + +// 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. +restoreFontPreferences(); export type { OmnigentHostConfig } from "./lib/host"; export type { RoutingApi } from "./lib/routing"; diff --git a/web/src/index.css b/web/src/index.css index a702d1391c..99a22b00b7 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -804,6 +804,18 @@ } } +/* User-controlled fixed-width (monospace chrome) font family. The `font-mono` + * utility inlines the literal `--font-mono` stack (Tailwind v4 `@theme inline`), + * so a runtime `--font-mono` override is a no-op; this unlayered rule — winning + * over the layered utility — swaps in `--ui-mono-font-family` when the Appearance + * setting sets it on documentElement (see lib/fixedWidthFontPreferences.ts), and + * falls back to the app mono stack when unset. Scoped to the `font-mono` utility + * only, so Monaco/xterm (which read `var(--font-mono)` directly) keep the code + * font, not this chrome one. */ +.font-mono { + font-family: var(--ui-mono-font-family, var(--font-mono)); +} + /* Mobile typography bump. * * Push the root font-size from the browser default (16px) to 18px below diff --git a/web/src/lib/codeFontPreferences.ts b/web/src/lib/codeFontPreferences.ts index 92c4a7c41c..154d83b7b0 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,34 @@ 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, "code"); + if (!entry) return; + void ready.then((loaded) => { + // Only re-emit once the glyphs genuinely arrived — a failed/blocked load + // resolves `false`, and re-measuring then would just churn against the + // fallback cell. + if (!loaded) return; + // 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/cssFontFamilyPreference.test.ts b/web/src/lib/cssFontFamilyPreference.test.ts new file mode 100644 index 0000000000..3453eafd16 --- /dev/null +++ b/web/src/lib/cssFontFamilyPreference.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + createCssFontFamilyPreference, + FONT_FAMILY_DEFAULT, + normalizeFontFamily, + readStoredFontFamily, + writeStoredFontFamily, +} from "./cssFontFamilyPreference"; + +const KEY = "omnigent:test-font-family"; +const CSS_VAR = "--test-font-family"; + +afterEach(() => { + localStorage.clear(); + document.documentElement.style.removeProperty(CSS_VAR); +}); + +describe("cssFontFamilyPreference — normalizeFontFamily", () => { + it("returns the empty default for non-strings", () => { + expect(normalizeFontFamily(42)).toBe(FONT_FAMILY_DEFAULT); + expect(normalizeFontFamily(null)).toBe(""); + expect(normalizeFontFamily(undefined)).toBe(""); + }); + + it("trims surrounding whitespace", () => { + expect(normalizeFontFamily(" Georgia ")).toBe("Georgia"); + }); + + it("preserves the punctuation a font stack relies on", () => { + expect(normalizeFontFamily('"Times New Roman", serif')).toBe('"Times New Roman", serif'); + }); + + it("strips characters that could break the CSS declaration", () => { + expect(normalizeFontFamily("Arial;}body{")).toBe("Arialbody"); + }); + + it("collapses an over-long value to the default", () => { + expect(normalizeFontFamily("x".repeat(200))).toBe(FONT_FAMILY_DEFAULT); + }); +}); + +describe("cssFontFamilyPreference — readStoredFontFamily / writeStoredFontFamily", () => { + it("round-trips a valid family name and returns the normalized write value", () => { + expect(writeStoredFontFamily(KEY, "Inter")).toBe("Inter"); + expect(readStoredFontFamily(KEY)).toBe("Inter"); + expect(localStorage.getItem(KEY)).toBe(JSON.stringify("Inter")); + }); + + it("clears the key when written empty or whitespace-only", () => { + writeStoredFontFamily(KEY, "Inter"); + expect(localStorage.getItem(KEY)).not.toBeNull(); + expect(writeStoredFontFamily(KEY, " ")).toBe(""); + expect(localStorage.getItem(KEY)).toBeNull(); + expect(readStoredFontFamily(KEY)).toBe(""); + }); + + it("returns the default when nothing is stored", () => { + expect(readStoredFontFamily(KEY)).toBe(FONT_FAMILY_DEFAULT); + }); + + it("falls back to the default on malformed JSON", () => { + localStorage.setItem(KEY, "}{not json"); + expect(readStoredFontFamily(KEY)).toBe(FONT_FAMILY_DEFAULT); + }); + + it("falls back to the default on a non-string value", () => { + localStorage.setItem(KEY, JSON.stringify(42)); + expect(readStoredFontFamily(KEY)).toBe(FONT_FAMILY_DEFAULT); + }); +}); + +describe("cssFontFamilyPreference — createCssFontFamilyPreference", () => { + const pref = createCssFontFamilyPreference({ + key: KEY, + cssVar: CSS_VAR, + fallback: "var(--font-sans)", + category: "sans", + }); + + it("read/write round-trip through the configured key", () => { + expect(pref.write("Inter")).toBe("Inter"); + expect(pref.read()).toBe("Inter"); + expect(localStorage.getItem(KEY)).toBe(JSON.stringify("Inter")); + }); + + it("applies the family with the fallback stack appended", () => { + pref.apply("Inter"); + expect(document.documentElement.style.getPropertyValue(CSS_VAR)).toBe( + "Inter, var(--font-sans)", + ); + }); + + it("removes the property when applied empty (default)", () => { + pref.apply("Inter"); + expect(document.documentElement.style.getPropertyValue(CSS_VAR)).toBe( + "Inter, var(--font-sans)", + ); + pref.apply(""); + expect(document.documentElement.style.getPropertyValue(CSS_VAR)).toBe(""); + }); + + it("uses each preference's own fallback stack", () => { + const mono = createCssFontFamilyPreference({ + key: "omnigent:test-mono-family", + cssVar: "--test-mono-family", + fallback: "ui-monospace, monospace", + category: "fixedWidth", + }); + mono.apply("Roboto Mono"); + expect(document.documentElement.style.getPropertyValue("--test-mono-family")).toBe( + "Roboto Mono, ui-monospace, monospace", + ); + document.documentElement.style.removeProperty("--test-mono-family"); + }); +}); diff --git a/web/src/lib/cssFontFamilyPreference.ts b/web/src/lib/cssFontFamilyPreference.ts new file mode 100644 index 0000000000..0f6f20b3ef --- /dev/null +++ b/web/src/lib/cssFontFamilyPreference.ts @@ -0,0 +1,143 @@ +// Shared core for CSS-variable-backed font-family preferences. +// +// The chrome/UI font (uiFontPreferences.ts) and the general monospace font both +// ride a CSS custom property on the document root: an unset property falls back +// to a system stack, and a set value wins. The read/normalize/persist/apply +// mechanics are identical across them — only the storage key, the CSS variable, +// the appended fallback stack, and the loader category differ. This module +// factors that common core out so each family is a thin config object. +// +// The code font (codeFontPreferences.ts) is deliberately NOT built on this: it +// can't ride a CSS variable (Monaco/xterm are fixed-pixel widgets) and needs a +// specialized immediate + post-load remeasure/refit pub/sub path. +// +// SSR/no-DOM safe: reads return the empty default with no `window`, and apply +// no-ops with no `document`, so boot-time restore on the server is harmless. + +import { loadFontByFamily } from "./webFontLoader"; +import type { FontCategory } from "./fontCatalog"; + +/** Empty string = the family's default: no override, falls back to the stack. */ +export const FONT_FAMILY_DEFAULT = ""; + +/** Longest family name we'll accept — a guard against a corrupt/oversized entry. */ +const FONT_FAMILY_MAX_LENGTH = 100; + +/** + * Normalize a raw family name into a value safe to persist and to set as a CSS + * custom property (or hand a code widget): trimmed, with characters that could + * terminate the declaration or open a new one (`;{}` and control chars) + * stripped. Over-long input collapses to the default. Returns "" for anything + * that isn't a usable family, so callers treat empty as the family default. + */ +export function normalizeFontFamily(value: unknown): string { + if (typeof value !== "string") return FONT_FAMILY_DEFAULT; + // eslint-disable-next-line no-control-regex -- intentionally stripping control chars + const cleaned = value.replace(/[;{}\x00-\x1f\x7f]/g, "").trim(); + if (!cleaned || cleaned.length > FONT_FAMILY_MAX_LENGTH) { + return FONT_FAMILY_DEFAULT; + } + return cleaned; +} + +/** + * Read a persisted font family from `localStorage[key]`. + * + * Returns "" (the family default) when nothing is stored, on a server render (no + * `window`), or when the stored value is missing/malformed — never throws, so a + * corrupt entry can't break app boot. + */ +export function readStoredFontFamily(key: string): string { + if (typeof window === "undefined") return FONT_FAMILY_DEFAULT; + try { + const raw = window.localStorage.getItem(key); + if (!raw) return FONT_FAMILY_DEFAULT; + const parsed: unknown = JSON.parse(raw); + return normalizeFontFamily(parsed); + } catch { + return FONT_FAMILY_DEFAULT; + } +} + +/** + * Persist a font family under `localStorage[key]`, returning the normalized name + * that was applied. An empty (or all-stripped) name clears the preference — + * reverting to the family default — rather than storing a blank. Swallows + * quota/access errors so a failed write can't break the app. + */ +export function writeStoredFontFamily(key: string, name: string): string { + const normalized = normalizeFontFamily(name); + if (typeof window === "undefined") return normalized; + try { + if (!normalized) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, JSON.stringify(normalized)); + } + } catch { + // localStorage quota or access errors shouldn't break the app. + } + return normalized; +} + +/** Config for a CSS-variable-backed font-family preference. */ +export interface CssFontFamilyPreferenceConfig { + /** localStorage key the value is persisted under. */ + readonly key: string; + /** CSS custom property set on the document root, e.g. `--ui-font-family`. */ + readonly cssVar: string; + /** + * Fallback stack appended after the chosen family in the CSS value, e.g. + * `var(--font-sans)`. So an uninstalled/partial name degrades to this rather + * than the browser's default serif. The `var(--x, …)` fallback in the CSS only + * fires when the property is UNSET, not when it holds an unusable name, so the + * fallback has to live inside the value too. + */ + readonly fallback: string; + /** Loader category, so a family shared across roles loads the right entry. */ + readonly category: FontCategory; +} + +/** The read/write/apply trio for one CSS-variable-backed font family. */ +export interface CssFontFamilyPreference { + /** Read the persisted family ("" = default). Never throws. */ + read(): string; + /** Persist the family; returns the normalized name written ("" cleared it). */ + write(name: string): string; + /** + * Apply the family to the document root's CSS variable. An empty name removes + * the property (restoring the fallback stack); a catalog name also kicks a + * fire-and-forget webfont load so the glyphs arrive (font-display: swap paints + * the swap). This is the single source of the DOM side-effect. + */ + apply(name: string): void; +} + +/** + * Build the read/write/apply trio for a CSS-variable-backed font family (the + * UI and fixed-width shape). See {@link CssFontFamilyPreferenceConfig}. + */ +export function createCssFontFamilyPreference( + config: CssFontFamilyPreferenceConfig, +): CssFontFamilyPreference { + const { key, cssVar, fallback, category } = config; + return { + read: () => readStoredFontFamily(key), + write: (name: string) => writeStoredFontFamily(key, name), + apply: (name: string) => { + if (typeof document === "undefined") return; + const normalized = normalizeFontFamily(name); + if (!normalized) { + document.documentElement.style.removeProperty(cssVar); + 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 `, ` + // covers the gap before load and any name that never resolves. + void loadFontByFamily(normalized, category); + document.documentElement.style.setProperty(cssVar, `${normalized}, ${fallback}`); + }, + }; +} diff --git a/web/src/lib/fixedWidthFontPreferences.test.ts b/web/src/lib/fixedWidthFontPreferences.test.ts new file mode 100644 index 0000000000..b6dabe6098 --- /dev/null +++ b/web/src/lib/fixedWidthFontPreferences.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + applyFixedWidthFontFamily, + FIXED_WIDTH_FONT_FAMILY_DEFAULT, + FIXED_WIDTH_FONT_FAMILY_FALLBACK, + readFixedWidthFontFamily, + writeFixedWidthFontFamily, +} from "./fixedWidthFontPreferences"; + +const FAMILY_STORAGE_KEY = "omnigent:fixed-width-font-family"; + +afterEach(() => { + localStorage.clear(); + document.documentElement.style.removeProperty("--ui-mono-font-family"); +}); + +describe("fixedWidthFontPreferences", () => { + it("returns the empty default when nothing is stored", () => { + expect(readFixedWidthFontFamily()).toBe(FIXED_WIDTH_FONT_FAMILY_DEFAULT); + expect(readFixedWidthFontFamily()).toBe(""); + }); + + it("round-trips a valid family under the dedicated key", () => { + writeFixedWidthFontFamily("IBM Plex Mono"); + expect(readFixedWidthFontFamily()).toBe("IBM Plex Mono"); + expect(localStorage.getItem(FAMILY_STORAGE_KEY)).toBe(JSON.stringify("IBM Plex Mono")); + }); + + it("trims surrounding whitespace", () => { + writeFixedWidthFontFamily(" Roboto Mono "); + expect(readFixedWidthFontFamily()).toBe("Roboto Mono"); + }); + + it("clears the preference when written empty or whitespace-only", () => { + writeFixedWidthFontFamily("IBM Plex Mono"); + expect(localStorage.getItem(FAMILY_STORAGE_KEY)).not.toBeNull(); + writeFixedWidthFontFamily(" "); + // Empty input removes the key rather than storing a blank string. + expect(localStorage.getItem(FAMILY_STORAGE_KEY)).toBeNull(); + expect(readFixedWidthFontFamily()).toBe(""); + }); + + it("strips characters that could break the CSS declaration", () => { + writeFixedWidthFontFamily("Roboto Mono;}body{"); + expect(readFixedWidthFontFamily()).toBe("Roboto Monobody"); + }); + + it("falls back to the default on a value longer than the cap", () => { + writeFixedWidthFontFamily("x".repeat(200)); + expect(readFixedWidthFontFamily()).toBe(FIXED_WIDTH_FONT_FAMILY_DEFAULT); + expect(localStorage.getItem(FAMILY_STORAGE_KEY)).toBeNull(); + }); + + it("falls back to the default on malformed JSON", () => { + // Corrupt localStorage should not break app boot. + localStorage.setItem(FAMILY_STORAGE_KEY, "}{not json"); + expect(readFixedWidthFontFamily()).toBe(FIXED_WIDTH_FONT_FAMILY_DEFAULT); + }); + + it("applies the family with the mono stack appended as a fallback", () => { + // The mono stack is appended so an uninstalled/partial name degrades to the + // app's default mono, not a browser default. + applyFixedWidthFontFamily("IBM Plex Mono"); + expect(document.documentElement.style.getPropertyValue("--ui-mono-font-family")).toBe( + `IBM Plex Mono, ${FIXED_WIDTH_FONT_FAMILY_FALLBACK}`, + ); + }); + + it("removes the custom property when applied empty (Default)", () => { + applyFixedWidthFontFamily("IBM Plex Mono"); + expect(document.documentElement.style.getPropertyValue("--ui-mono-font-family")).toBe( + `IBM Plex Mono, ${FIXED_WIDTH_FONT_FAMILY_FALLBACK}`, + ); + applyFixedWidthFontFamily(""); + // Removing the property lets the .font-mono rule fall back to var(--font-mono). + expect(document.documentElement.style.getPropertyValue("--ui-mono-font-family")).toBe(""); + }); +}); diff --git a/web/src/lib/fixedWidthFontPreferences.ts b/web/src/lib/fixedWidthFontPreferences.ts new file mode 100644 index 0000000000..1c34062c0a --- /dev/null +++ b/web/src/lib/fixedWidthFontPreferences.ts @@ -0,0 +1,75 @@ +// Persisted, app-global preference for the FIXED-WIDTH font — the family used +// by general monospace UI chrome (the `font-mono` utility: file paths, hashes, +// inline code chips, log rows, …). This is a distinct role from both the +// chrome/UI sans font (see lib/uiFontPreferences.ts) and the code editor / +// terminal font (see lib/codeFontPreferences.ts). +// +// Like the UI sans font it rides a CSS custom property, `--ui-mono-font-family`, +// so it uses the shared CSS-variable-backed shape. It can't reuse `--font-mono`: +// Tailwind v4's `@theme inline` block inlines the literal mono stack into the +// `font-mono` utility rather than a `var()`, so setting `--font-mono` at runtime +// is a no-op. An unlayered `.font-mono` rule in index.css reads +// `var(--ui-mono-font-family, var(--font-mono))`, so an unset preference falls +// back to the app mono stack and any value we set on documentElement wins. + +import { createCssFontFamilyPreference } from "./cssFontFamilyPreference"; + +const FONT_FAMILY_STORAGE_KEY = "omnigent:fixed-width-font-family"; + +/** Empty string = "Default": no override, falls back to the `--font-mono` stack. */ +export const FIXED_WIDTH_FONT_FAMILY_DEFAULT = ""; + +/** + * The mono stack the fixed-width font falls back to when no custom family is set + * (or an uninstalled name is chosen). It's the `--font-mono` variable rather + * than a literal so the CSS var and the appended fallback stay in lockstep + * (mirrors {@link UI_FONT_FAMILY_FALLBACK}). + */ +export const FIXED_WIDTH_FONT_FAMILY_FALLBACK = "var(--font-mono)"; + +// The whole fixed-width font-family preference — read/normalize/persist/apply — +// is the shared CSS-variable-backed shape. Setting `--ui-mono-font-family` on +// the document root drives the `.font-mono` rule in index.css; the `fixedWidth` +// category loads the right catalog entry for a shared family. +const fixedWidthFontFamilyPreference = createCssFontFamilyPreference({ + key: FONT_FAMILY_STORAGE_KEY, + cssVar: "--ui-mono-font-family", + fallback: FIXED_WIDTH_FONT_FAMILY_FALLBACK, + category: "fixedWidth", +}); + +/** + * Read the persisted fixed-width font family. + * + * Returns "" (Default) when nothing is stored, on a server render (no `window`), + * or when the stored value is missing/malformed — never throws, so a corrupt + * entry can't break app boot. + */ +export function readFixedWidthFontFamily(): string { + return fixedWidthFontFamilyPreference.read(); +} + +/** + * Persist the fixed-width font family. An empty (or all-stripped) name clears + * the preference — reverting to Default — rather than storing a blank. Swallows + * quota/access errors so a failed write can't break the app. + */ +export function writeFixedWidthFontFamily(name: string): void { + fixedWidthFontFamilyPreference.write(name); +} + +/** + * Apply the given family to the DOM by setting the `--ui-mono-font-family` + * variable on the document root; the `.font-mono` rule in index.css reads it as + * the monospace-chrome font. An empty name removes the property, restoring the + * `--font-mono` stack. + * + * The chosen family is applied WITH the mono stack appended + * (`, var(--font-mono)`) so a name that isn't installed — or a partial one + * typed so far — degrades to the app mono rather than a browser default. Also + * kicks a fire-and-forget webfont load for a catalog family. This is the single + * source of the DOM side-effect. + */ +export function applyFixedWidthFontFamily(name: string): void { + fixedWidthFontFamilyPreference.apply(name); +} diff --git a/web/src/lib/fontCatalog.test.ts b/web/src/lib/fontCatalog.test.ts new file mode 100644 index 0000000000..84927fc3c9 --- /dev/null +++ b/web/src/lib/fontCatalog.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "vitest"; +import { + FONT_CATALOG, + FONT_CATALOG_BY_CATEGORY, + type FontCategory, + fontLoadKey, + getFontByFamily, + getFontById, + getFontsByFamily, +} 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 deterministically", () => { + // IBM Plex Mono is offered in both fixedWidth and code; the single-arg + // lookup resolves to the FIRST catalog occurrence (backward-compatible). + const entry = getFontByFamily("IBM Plex Mono"); + expect(entry).toBeDefined(); + expect(entry?.family).toBe("IBM Plex Mono"); + expect(entry?.category).toBe("fixedWidth"); + }); + + it("returns every entry for a shared family via getFontsByFamily", () => { + const matches = getFontsByFamily("IBM Plex Mono"); + expect(matches.length).toBe(2); + expect(new Set(matches.map((e) => e.category))).toEqual(new Set(["fixedWidth", "code"])); + // Distinct ids, so PR 2 can address each independently. + expect(new Set(matches.map((e) => e.id)).size).toBe(2); + expect(getFontsByFamily("Comic Sans MS")).toEqual([]); + expect(getFontsByFamily("")).toEqual([]); + }); + + it("resolves a shared family to the requested category", () => { + // The whole point of the category arg: the code control must get the code + // entry, the mono control the fixedWidth one — not always the first match. + expect(getFontByFamily("IBM Plex Mono", "code")?.category).toBe("code"); + expect(getFontByFamily("IBM Plex Mono", "code")?.id).toBe("ibm-plex-mono-code"); + expect(getFontByFamily("IBM Plex Mono", "fixedWidth")?.category).toBe("fixedWidth"); + expect(getFontByFamily("IBM Plex Mono", "fixedWidth")?.id).toBe("ibm-plex-mono"); + }); + + it("falls back to the first match when the family isn't in the asked category", () => { + // Fira Code is code-only; asking for it as sans still resolves (delivery + // metadata is identical across a family's entries, so it still loads). + const entry = getFontByFamily("Fira Code", "sans"); + expect(entry?.id).toBe("fira-code"); + }); +}); + +describe("fontCatalog — fontLoadKey (resource identity)", () => { + it("keys google-css2 entries by their stylesheet URL", () => { + const inter = getFontById("inter"); + expect(fontLoadKey(inter!)).toBe(inter!.cssUrl); + }); + + it("gives the two IBM Plex Mono entries the SAME load key", () => { + // Different ids, identical Google CSS2 URL → identical resource → one key, + // so the loader dedupes them (see webFontLoader dedup test). + const fixed = getFontById("ibm-plex-mono")!; + const code = getFontById("ibm-plex-mono-code")!; + expect(fixed.id).not.toBe(code.id); + expect(fontLoadKey(fixed)).toBe(fontLoadKey(code)); + }); + + it("keys self-hosted entries by their face URLs", () => { + const nerd = getFontById("jetbrainsmono-nerd-font-mono")!; + expect(fontLoadKey(nerd)).toContain(nerd.faces![0].url); + }); + + it("gives distinct resources distinct keys", () => { + const keys = FONT_CATALOG.filter((e) => e.source !== "bundled").map(fontLoadKey); + // The only intentional collision is the shared IBM Plex Mono URL. + const dupes = keys.filter((k, i) => keys.indexOf(k) !== i); + expect(new Set(dupes).size).toBe(1); + }); +}); diff --git a/web/src/lib/fontCatalog.ts b/web/src/lib/fontCatalog.ts new file mode 100644 index 0000000000..65d0f7b82b --- /dev/null +++ b/web/src/lib/fontCatalog.ts @@ -0,0 +1,289 @@ +// 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" }]; +} + +/** + * A Google Fonts CSS2 catalog entry: label mirrors the family, `id` is the + * stable slug, and the stylesheet href is built from the family + weights. Keeps + * the ~13 Google entries from repeating the family name four times each. + */ +function googleFont( + category: FontCategory, + id: string, + family: string, + weights: readonly number[], +): FontCatalogEntry { + return { + id, + label: family, + family, + category, + source: "google-css2", + cssUrl: googleCss2Url(family, weights), + }; +} + +// ---- 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" }, + googleFont("sans", "inter", "Inter", [400, 500, 600, 700]), + googleFont("sans", "roboto", "Roboto", [400, 500, 700]), + googleFont("sans", "open-sans", "Open Sans", [400, 600, 700]), + googleFont("sans", "lato", "Lato", [400, 700]), + googleFont("sans", "source-sans-3", "Source Sans 3", [400, 600, 700]), + googleFont("sans", "geist", "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", + }, + googleFont("fixedWidth", "ibm-plex-mono", "IBM Plex Mono", [400, 500, 600, 700]), + googleFont("fixedWidth", "roboto-mono", "Roboto Mono", [400, 500, 700]), + googleFont("fixedWidth", "space-mono", "Space Mono", [400, 700]), +]; + +// ---- Code (editor + terminal) --------------------------------------------- + +const CODE_FONTS: readonly FontCatalogEntry[] = [ + googleFont("code", "jetbrains-mono", "JetBrains Mono", [400, 500, 700]), + googleFont("code", "fira-code", "Fira Code", [400, 500, 700]), + googleFont("code", "source-code-pro", "Source Code Pro", [400, 500, 700]), + // Shares the fixedWidth IBM Plex Mono stylesheet URL (same family + weights), + // so the loader dedupes them by resource identity — distinct id, one fetch. + googleFont("code", "ibm-plex-mono-code", "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 → ALL matching entries, in catalog order. A family (e.g. +// "IBM Plex Mono") can appear in more than one category, so we keep every match +// rather than the first — a category-aware lookup needs the right one. +const BY_FAMILY = new Map(); +for (const entry of FONT_CATALOG) { + const key = entry.family.trim().toLowerCase(); + if (!key) continue; + const list = BY_FAMILY.get(key); + if (list) list.push(entry); + else BY_FAMILY.set(key, [entry]); +} + +/** + * All catalog entries offered under a family NAME, case-insensitively (empty + * for a non-catalog family or the empty/System-default name). A shared family + * like "IBM Plex Mono" returns one entry per category it appears in. + */ +export function getFontsByFamily(family: string): readonly FontCatalogEntry[] { + const key = family.trim().toLowerCase(); + if (!key) return []; + return BY_FAMILY.get(key) ?? []; +} + +/** + * Resolve a typed/stored family NAME to a catalog entry, case-insensitively. + * + * The bridge from the free-text font inputs (which store a bare family string) + * to the loader: a typed name matching a catalog family resolves to an entry to + * load; anything else (a locally-installed font, a partial name, the empty + * System-default name) resolves to `undefined` and is left to the OS. + * + * When `category` is given (e.g. PR 2's per-role dropdowns), the entry from that + * category wins — so a shared family like "IBM Plex Mono" resolves to its `code` + * entry for the code control and its `fixedWidth` entry for the mono control. If + * the family isn't offered in that category, the first match is returned anyway: + * the delivery metadata is identical across a family's entries, so the font + * still loads. With no `category`, the first catalog occurrence wins + * (backward-compatible). + */ +export function getFontByFamily( + family: string, + category?: FontCategory, +): FontCatalogEntry | undefined { + const matches = getFontsByFamily(family); + if (matches.length === 0) return undefined; + if (category) return matches.find((e) => e.category === category) ?? matches[0]; + return matches[0]; +} + +/** + * The canonical load key for an entry: its underlying resource identity, NOT its + * catalog id. Two entries with different ids but the same delivery resource (e.g. + * the `fixedWidth` and `code` IBM Plex Mono entries share one Google CSS2 URL) + * produce the same key, so the loader injects the stylesheet/faces once and + * shares a single in-flight load across them. + */ +export function fontLoadKey(entry: FontCatalogEntry): string { + if (entry.source === "google-css2") return entry.cssUrl ?? `id:${entry.id}`; + if (entry.source === "self-hosted") { + const urls = (entry.faces ?? []).map((f) => f.url).join("|"); + return urls || `id:${entry.id}`; + } + return `bundled:${entry.id}`; +} diff --git a/web/src/lib/restoreFontPreferences.test.ts b/web/src/lib/restoreFontPreferences.test.ts new file mode 100644 index 0000000000..a7dd6645ea --- /dev/null +++ b/web/src/lib/restoreFontPreferences.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { restoreFontPreferences } from "./restoreFontPreferences"; +import { loadFontByFamily } from "./webFontLoader"; + +// Spy on the loader so we can assert a saved family's webfont load is kicked off +// on boot, per role, without depending on jsdom's font machinery. +vi.mock("./webFontLoader", () => ({ + loadFontByFamily: vi.fn(() => ({ entry: undefined, ready: Promise.resolve(false) })), +})); + +const mockLoadFontByFamily = vi.mocked(loadFontByFamily); + +afterEach(() => { + localStorage.clear(); + document.documentElement.style.removeProperty("--ui-font-scale"); + document.documentElement.style.removeProperty("--ui-font-family"); + document.documentElement.style.removeProperty("--ui-mono-font-family"); + vi.clearAllMocks(); +}); + +describe("restoreFontPreferences", () => { + it("applies the saved UI size + family to the document root on boot", () => { + localStorage.setItem("omnigent:ui-font-size", JSON.stringify(20)); + localStorage.setItem("omnigent:ui-font-family", JSON.stringify("Inter")); + + restoreFontPreferences(); + + // 20 / 16 base = 1.25. + expect(document.documentElement.style.getPropertyValue("--ui-font-scale")).toBe("1.25"); + expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe( + "Inter, var(--font-sans)", + ); + }); + + it("restores the saved fixed-width family on boot: applies its CSS var and loads it", () => { + localStorage.setItem("omnigent:fixed-width-font-family", JSON.stringify("IBM Plex Mono")); + + restoreFontPreferences(); + + // The fixed-width family lands on its own CSS var with the mono fallback + // appended — not cross-wired to the UI var. + expect(document.documentElement.style.getPropertyValue("--ui-mono-font-family")).toBe( + "IBM Plex Mono, var(--font-mono)", + ); + // And its webfont load is kicked off under the fixedWidth category so a + // catalog family is fetched on boot, not only on the next Settings change. + expect(mockLoadFontByFamily).toHaveBeenCalledWith("IBM Plex Mono", "fixedWidth"); + }); + + it("applies defaults when nothing is stored (no throw)", () => { + expect(() => restoreFontPreferences()).not.toThrow(); + // Default size 16 → scale 1; families unset → no overrides. + expect(document.documentElement.style.getPropertyValue("--ui-font-scale")).toBe("1"); + expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(""); + expect(document.documentElement.style.getPropertyValue("--ui-mono-font-family")).toBe(""); + }); +}); diff --git a/web/src/lib/restoreFontPreferences.ts b/web/src/lib/restoreFontPreferences.ts new file mode 100644 index 0000000000..bcb912063e --- /dev/null +++ b/web/src/lib/restoreFontPreferences.ts @@ -0,0 +1,38 @@ +// Boot-time font restoration, shared by the standalone (main.tsx) and embed +// (embed.tsx) entry points. +// +// The saved font preferences must be applied before first paint so there's no +// flash, and the catalog webfont loads kicked off so a chosen font is fetched on +// boot rather than only on the next Settings change. Both entry points need the +// exact same calls, so they live here once. +// +// SSR/no-DOM safe: every apply/load helper guards for a missing document. + +import { + applyUiFontFamily, + applyUiFontScale, + readUiFontFamily, + readUiFontSizePx, +} from "./uiFontPreferences"; +import { applyFixedWidthFontFamily, readFixedWidthFontFamily } from "./fixedWidthFontPreferences"; +import { loadCodeFontFamily, readCodeFontFamily } from "./codeFontPreferences"; + +/** + * Restore the saved UI + fixed-width + code font preferences on boot. + * + * - UI font: applies the size (`--ui-font-scale`) and family (`--ui-font-family`) + * to the document root; `applyUiFontFamily` also kicks the catalog webfont + * load for the saved family. + * - Fixed-width font: rides `--ui-mono-font-family` like the UI font; + * `applyFixedWidthFontFamily` sets the var and kicks the catalog webfont load + * for the saved family. + * - Code font: rides a pub/sub (not a CSS var), so nothing loads it unless we + * ask — `loadCodeFontFamily` fetches it and mounted editors/terminals + * re-measure when it lands. + */ +export function restoreFontPreferences(): void { + applyUiFontScale(readUiFontSizePx()); + applyUiFontFamily(readUiFontFamily()); + applyFixedWidthFontFamily(readFixedWidthFontFamily()); + loadCodeFontFamily(readCodeFontFamily()); +} diff --git a/web/src/lib/uiFontPreferences.test.ts b/web/src/lib/uiFontPreferences.test.ts index cf691f1dbb..fdb30f7935 100644 --- a/web/src/lib/uiFontPreferences.test.ts +++ b/web/src/lib/uiFontPreferences.test.ts @@ -5,6 +5,7 @@ import { readUiFontFamily, readUiFontSizePx, UI_FONT_FAMILY_DEFAULT, + UI_FONT_FAMILY_FALLBACK, UI_FONT_SIZE_DEFAULT, UI_FONT_SIZE_MAX, UI_FONT_SIZE_MIN, @@ -134,14 +135,14 @@ describe("uiFontPreferences — family", () => { // the app's default sans, not the browser's default serif. applyUiFontFamily("Inter"); expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe( - "Inter, var(--font-sans)", + `Inter, ${UI_FONT_FAMILY_FALLBACK}`, ); }); it("removes the custom property when applied empty (System default)", () => { applyUiFontFamily("Inter"); expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe( - "Inter, var(--font-sans)", + `Inter, ${UI_FONT_FAMILY_FALLBACK}`, ); applyUiFontFamily(""); // Removing the property lets the html rule fall back to var(--font-sans). diff --git a/web/src/lib/uiFontPreferences.ts b/web/src/lib/uiFontPreferences.ts index c1c9b32fad..f1810f4f95 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 { createCssFontFamilyPreference } from "./cssFontFamilyPreference"; + const STORAGE_KEY = "omnigent:ui-font-size"; /** Reference size that a scale of 1 corresponds to (Tailwind/browser default). */ @@ -88,25 +90,25 @@ const FONT_FAMILY_STORAGE_KEY = "omnigent:ui-font-family"; /** Empty string = "System default": no override, falls back to `--font-sans`. */ export const UI_FONT_FAMILY_DEFAULT = ""; -/** Longest family name we'll accept — a guard against a corrupt/oversized entry. */ -const UI_FONT_FAMILY_MAX_LENGTH = 100; - /** - * Normalize a raw family name into a value safe to persist and to set as a CSS - * custom property: trimmed, with characters that could terminate the - * declaration or open a new one (`;{}` and control chars) stripped. Over-long - * input collapses to the default. Returns "" for anything that isn't a usable - * family, so callers treat empty as "System default". + * The sans stack the UI font falls back to when no custom family is set (or an + * uninstalled name is chosen). It's the `--font-sans` variable rather than a + * literal so the CSS var and the appended fallback stay in lockstep, and so + * SettingsPage can name the fallback semantically instead of repeating the + * literal (mirrors {@link CODE_FONT_FAMILY_FALLBACK}). */ -function normalizeUiFontFamily(value: unknown): string { - if (typeof value !== "string") return UI_FONT_FAMILY_DEFAULT; - // eslint-disable-next-line no-control-regex -- intentionally stripping control chars - const cleaned = value.replace(/[;{}\x00-\x1f\x7f]/g, "").trim(); - if (!cleaned || cleaned.length > UI_FONT_FAMILY_MAX_LENGTH) { - return UI_FONT_FAMILY_DEFAULT; - } - return cleaned; -} +export const UI_FONT_FAMILY_FALLBACK = "var(--font-sans)"; + +// The whole UI font-family preference — read/normalize/persist/apply — is the +// shared CSS-variable-backed shape. Setting `--ui-font-family` on the document +// root drives the `html` rule in index.css; the `sans` category loads the right +// catalog entry for a shared family. +const uiFontFamilyPreference = createCssFontFamilyPreference({ + key: FONT_FAMILY_STORAGE_KEY, + cssVar: "--ui-font-family", + fallback: UI_FONT_FAMILY_FALLBACK, + category: "sans", +}); /** * Read the persisted UI font family. @@ -116,15 +118,7 @@ function normalizeUiFontFamily(value: unknown): string { * corrupt entry can't break app boot. */ export function readUiFontFamily(): string { - if (typeof window === "undefined") return UI_FONT_FAMILY_DEFAULT; - try { - const raw = window.localStorage.getItem(FONT_FAMILY_STORAGE_KEY); - if (!raw) return UI_FONT_FAMILY_DEFAULT; - const parsed: unknown = JSON.parse(raw); - return normalizeUiFontFamily(parsed); - } catch { - return UI_FONT_FAMILY_DEFAULT; - } + return uiFontFamilyPreference.read(); } /** @@ -133,17 +127,7 @@ export function readUiFontFamily(): string { * quota/access errors so a failed write can't break the app. */ export function writeUiFontFamily(name: string): void { - if (typeof window === "undefined") return; - try { - const normalized = normalizeUiFontFamily(name); - if (!normalized) { - window.localStorage.removeItem(FONT_FAMILY_STORAGE_KEY); - return; - } - window.localStorage.setItem(FONT_FAMILY_STORAGE_KEY, JSON.stringify(normalized)); - } catch { - // localStorage quota or access errors shouldn't break the app. - } + uiFontFamilyPreference.write(name); } /** @@ -154,17 +138,9 @@ export function writeUiFontFamily(name: string): void { * The chosen family is applied WITH the system stack appended * (`, var(--font-sans)`) so a name that isn't installed — or a partial one * typed so far — degrades to the app's default sans rather than the browser's - * default serif. (The `var(--ui-font-family, …)` fallback in the CSS only fires - * when the property is unset, not when it holds an unusable name, so the - * fallback has to live inside the value too.) This is the single source of the - * DOM side-effect. + * default serif. Also kicks a fire-and-forget webfont load for a catalog family. + * This is the single source of the DOM side-effect. */ export function applyUiFontFamily(name: string): void { - if (typeof document === "undefined") return; - const normalized = normalizeUiFontFamily(name); - if (!normalized) { - document.documentElement.style.removeProperty("--ui-font-family"); - return; - } - document.documentElement.style.setProperty("--ui-font-family", `${normalized}, var(--font-sans)`); + uiFontFamilyPreference.apply(name); } diff --git a/web/src/lib/webFontLoader.test.ts b/web/src/lib/webFontLoader.test.ts new file mode 100644 index 0000000000..190e183dc3 --- /dev/null +++ b/web/src/lib/webFontLoader.test.ts @@ -0,0 +1,347 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { type FontCatalogEntry, getFontById } from "./fontCatalog"; +import { loadFont, loadFontByFamily, resetFontLoaderForTests } from "./webFontLoader"; + +// A resolvable promise handle for driving readiness/link ordering in tests. +function deferred() { + let resolve!: (v: T) => void; + let reject!: (e: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +// Records each `document.fonts.load(spec)` call and hands back a deferred whose +// resolution the test controls, so readiness ordering is observable rather than +// resolving instantly. +type FontsLoadCall = { spec: string; deferred: ReturnType> }; +let fontsLoadCalls: FontsLoadCall[] = []; + +beforeEach(() => { + fontsLoadCalls = []; + Object.defineProperty(document, "fonts", { + configurable: true, + value: { + load: vi.fn((spec: string) => { + const d = deferred(); + fontsLoadCalls.push({ spec, deferred: d }); + return d.promise; + }), + }, + }); +}); + +afterEach(() => { + // resetFontLoaderForTests removes the nodes it injected, so no hand-cleanup. + resetFontLoaderForTests(); + vi.restoreAllMocks(); +}); + +/** Injected stylesheet nodes (jsdom won't fire their load event itself). */ +function links(): HTMLLinkElement[] { + return [...document.querySelectorAll(`link[data-omnigent-font]`)]; +} +/** Injected @font-face