Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions web/src/components/FontFamilyCombobox.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Parameters<typeof FontFamilyCombobox>[0]> = {}) {
const onChange = vi.fn();
render(
<FontFamilyCombobox
category="sans"
value=""
onChange={onChange}
defaultLabel="System default"
ariaLabel="UI font family"
testId="test-font"
previewFallback="var(--font-sans)"
{...props}
/>,
);
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");
});
});
222 changes: 222 additions & 0 deletions web/src/components/FontFamilyCombobox.tsx
Original file line number Diff line number Diff line change
@@ -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 (`<testId>-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<FontOption[]>(() => {
const seen = new Set<string>();
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<FontOption | null>(() => {
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 (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setSearch("");
}}
>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
aria-expanded={open}
aria-label={ariaLabel}
data-testid={`${testId}-trigger`}
className={cn(
"flex h-9 w-56 items-center justify-between gap-2 rounded-md border border-input bg-background px-3 text-sm shadow-xs transition-colors outline-none dark:bg-input/30",
"hover:border-border-strong focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
)}
>
<span className="min-w-0 truncate" style={value ? previewStyle(value) : undefined}>
{selectedLabel}
</span>
<ChevronsUpDownIcon className="size-4 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 p-0" data-testid={`${testId}-popover`}>
<Command
// Search matches labels; keep the typed custom entry visible too.
filter={(itemValue, query) =>
itemValue.toLowerCase().includes(query.trim().toLowerCase()) ? 1 : 0
}
>
<CommandInput
placeholder={`Search ${ariaLabel.toLowerCase()}…`}
aria-label={ariaLabel}
data-testid={`${testId}-input`}
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty>No fonts found.</CommandEmpty>
<CommandGroup>
{/* Default (no override). */}
<CommandItem
value={defaultLabel}
data-testid={`${testId}-option-default`}
data-checked={value === ""}
onSelect={() => select("")}
>
<span className="truncate">{defaultLabel}</span>
</CommandItem>

{/* An out-of-catalog stored family, so the current custom value shows. */}
{customOption && (
<CommandItem
value={customOption.label}
data-testid={`${testId}-option-custom-current`}
data-checked
onSelect={() => select(customOption.family)}
>
<span className="truncate" style={previewStyle(customOption.family)}>
{customOption.label}
</span>
<span className="ml-2 shrink-0 text-xs text-muted-foreground">Custom</span>
</CommandItem>
)}

{options.map((option) => (
<CommandItem
key={option.family}
value={option.label}
data-testid={`${testId}-option-${option.family}`}
data-checked={option.family.toLowerCase() === value.toLowerCase()}
onSelect={() => select(option.family)}
>
<span className="truncate" style={previewStyle(option.family)}>
{option.label}
</span>
</CommandItem>
))}

{/* Free-text escape hatch: apply whatever family was typed. */}
{showCustomEntry && (
<CommandItem
value={`__custom__ ${trimmedSearch}`}
data-testid={`${testId}-option-custom`}
onSelect={() => select(trimmedSearch)}
>
<span className="truncate" style={previewStyle(trimmedSearch)}>
Use “{trimmedSearch}”
</span>
</CommandItem>
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
9 changes: 9 additions & 0 deletions web/src/embed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
12 changes: 12 additions & 0 deletions web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading