From 316c0019e273b855d93509c9b3b72d57a463b9f1 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:31:47 +0200 Subject: [PATCH 01/47] =?UTF-8?q?feat(web):=20configurable=20font=20famili?= =?UTF-8?q?es=20under=20Settings=20=E2=86=92=20Appearance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Fonts section to Appearance with four client-persisted preferences — interface (sans), composer, code, and terminal font families. Family only; sizing is deliberately left for a follow-up. - contracts: four FontFamilyPreference client settings (empty string means default), patchable per key - the font theme tokens move out of the inline Tailwind theme so utilities reference the variables, and a FontAppearanceSync applies custom families as CSS variable overrides with the default stacks kept as fallbacks - the composer gets its own --font-composer variable (defaults to the sans stack) applied on the editor surface, so prompt writers can use a mono face without changing the rest of the app - the terminal preference feeds the Ghostty surface's font option and live setFont from #4860's groundwork; the bundled Nerd Font symbols fallback keeps prompt glyphs on any custom face - each settings row shows a live preview rendered in the resolved stack, with per-row reset; custom names are normalized and quoted through tested pure helpers Co-Authored-By: Claude Fable 5 --- apps/web/src/appearanceFonts.test.ts | 39 ++++++++ apps/web/src/appearanceFonts.ts | 69 +++++++++++++ .../src/components/ComposerPromptEditor.tsx | 2 +- .../src/components/ThreadTerminalDrawer.tsx | 13 +++ .../components/settings/SettingsPanels.tsx | 99 +++++++++++++++++++ apps/web/src/index.css | 23 ++++- apps/web/src/routes/__root.tsx | 18 ++++ packages/contracts/src/settings.ts | 15 +++ 8 files changed, 272 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/appearanceFonts.test.ts create mode 100644 apps/web/src/appearanceFonts.ts diff --git a/apps/web/src/appearanceFonts.test.ts b/apps/web/src/appearanceFonts.test.ts new file mode 100644 index 000000000000..e25c0d0b150a --- /dev/null +++ b/apps/web/src/appearanceFonts.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + DEFAULT_CODE_FONT_STACK, + DEFAULT_SANS_FONT_STACK, + appearanceFontStack, + cssFontFamilies, +} from "./appearanceFonts"; + +describe("cssFontFamilies", () => { + it("returns null for effectively empty input", () => { + expect(cssFontFamilies("")).toBeNull(); + expect(cssFontFamilies(" ")).toBeNull(); + expect(cssFontFamilies(" , , ")).toBeNull(); + }); + + it("quotes names with spaces and keeps single idents bare", () => { + expect(cssFontFamilies("Fira Code")).toBe('"Fira Code"'); + expect(cssFontFamilies("monospace")).toBe("monospace"); + expect(cssFontFamilies('"Comic Mono"')).toBe('"Comic Mono"'); + }); + + it("normalizes comma-separated lists and strips embedded quotes", () => { + expect(cssFontFamilies(" Fira Code , Menlo ")).toBe('"Fira Code", Menlo'); + expect(cssFontFamilies('Bad"Name')).toBe('"BadName"'); + }); +}); + +describe("appearanceFontStack", () => { + it("prepends the custom family to the default stack", () => { + expect(appearanceFontStack("Fira Code", DEFAULT_CODE_FONT_STACK)).toBe( + `"Fira Code", ${DEFAULT_CODE_FONT_STACK}`, + ); + }); + + it("falls back to the default stack when unset", () => { + expect(appearanceFontStack("", DEFAULT_SANS_FONT_STACK)).toBe(DEFAULT_SANS_FONT_STACK); + }); +}); diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts new file mode 100644 index 000000000000..16a36bb16451 --- /dev/null +++ b/apps/web/src/appearanceFonts.ts @@ -0,0 +1,69 @@ +/** + * Font-family preferences from Settings → Appearance, applied as CSS custom + * properties. The default stacks mirror the `--font-sans` / `--font-mono` + * definitions in `index.css`; a custom family is always prepended to the + * matching default stack so glyph coverage never regresses. + */ + +export const DEFAULT_SANS_FONT_STACK = + '"DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, ' + + "sans-serif"; + +export const DEFAULT_CODE_FONT_STACK = + '"SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace'; + +function quoteFontFamilyName(name: string): string { + const bare = name.trim(); + if (bare.length === 0) return ""; + // Already quoted, or a single ident that needs no quoting. + if (/^(['"]).*\1$/.test(bare)) return bare; + if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(bare)) return bare; + return `"${bare.replaceAll('"', "")}"`; +} + +/** + * Normalize a user-entered family (single name or comma-separated list) into a + * safe CSS font-family list, or null when the input is effectively empty. + */ +export function cssFontFamilies(input: string): string | null { + const families = input + .split(",") + .map(quoteFontFamilyName) + .filter((name) => name.length > 0); + return families.length > 0 ? families.join(", ") : null; +} + +/** The full stack a preference resolves to: custom families before the default. */ +export function appearanceFontStack(custom: string, defaultStack: string): string { + const families = cssFontFamilies(custom); + return families === null ? defaultStack : `${families}, ${defaultStack}`; +} + +export interface AppearanceFontPreferences { + readonly sans: string; + readonly code: string; + readonly composer: string; +} + +/** + * Apply the preferences to the root element. Unset preferences remove the + * override so the stylesheet defaults (and theme changes) stay in charge. + */ +export function applyAppearanceFontVariables( + root: HTMLElement, + preferences: AppearanceFontPreferences, +): void { + const assignments: ReadonlyArray = [ + ["--font-sans", cssFontFamilies(preferences.sans), DEFAULT_SANS_FONT_STACK], + ["--font-mono", cssFontFamilies(preferences.code), DEFAULT_CODE_FONT_STACK], + // The composer falls back to whatever the sans preference resolves to. + ["--font-composer", cssFontFamilies(preferences.composer), "var(--font-sans)"], + ]; + for (const [variable, families, defaultStack] of assignments) { + if (families === null) { + root.style.removeProperty(variable); + } else { + root.style.setProperty(variable, `${families}, ${defaultStack}`); + } + } +} diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 169126788ae8..e80a515489f3 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1747,7 +1747,7 @@ function ComposerPromptEditorInner({ return ( -
+
terminalLabel); + const terminalFontFamily = useClientSettings((settings) => settings.fontFamilyTerminal); + const terminalFontFamilyRef = useRef(terminalFontFamily); const terminalSession = useAttachedTerminalSession({ environmentId, terminal: { @@ -367,6 +370,13 @@ export function TerminalViewport({ keybindingsRef.current = keybindings; }, [keybindings]); + useEffect(() => { + if (terminalFontFamilyRef.current === terminalFontFamily) return; + terminalFontFamilyRef.current = terminalFontFamily; + const family = terminalFontFamily.trim(); + void terminalRef.current?.setFont(family.length > 0 ? { family } : {}); + }, [terminalFontFamily]); + useEffect(() => { const mount = containerRef.current; if (!mount) return; @@ -380,6 +390,9 @@ export function TerminalViewport({ const setup = async (): Promise<(() => void) | null> => { const terminalOptions: GhosttyTerminalSurfaceOptions = { theme: terminalThemeFromApp(mount), + ...(terminalFontFamilyRef.current.length > 0 + ? { font: { family: terminalFontFamilyRef.current } } + : {}), onData: (data) => handleData(data), onResize: (cols, rows) => void resizeTerminal(cols, rows), onSelectionChange: () => handleSelectionChange(), diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 5385751924e1..d254150e2dbe 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -97,6 +97,12 @@ import { DialogTitle, } from "../ui/dialog"; import { DraftInput } from "../ui/draft-input"; +import { Input } from "../ui/input"; +import { + DEFAULT_CODE_FONT_STACK, + DEFAULT_SANS_FONT_STACK, + appearanceFontStack, +} from "../../appearanceFonts"; import { NumberField, NumberFieldDecrement, @@ -1109,10 +1115,103 @@ export function AppearanceSettingsPanel() { } /> + + + updateSettings({ fontFamilySans })} + /> + 0 + ? appearanceFontStack(settings.fontFamilyComposer, DEFAULT_SANS_FONT_STACK) + : appearanceFontStack(settings.fontFamilySans, DEFAULT_SANS_FONT_STACK) + } + value={settings.fontFamilyComposer} + onValueChange={(fontFamilyComposer) => updateSettings({ fontFamilyComposer })} + /> + updateSettings({ fontFamilyCode })} + /> + updateSettings({ fontFamilyTerminal })} + /> + ); } +function FontFamilySettingsRow({ + title, + description, + placeholder, + previewText, + previewStack, + value, + onValueChange, +}: { + title: string; + description: string; + placeholder: string; + previewText: string; + previewStack: string; + value: string; + onValueChange: (value: string) => void; +}) { + return ( + + {previewText} + + } + resetAction={ + value.trim().length > 0 ? ( + onValueChange("")} + /> + ) : null + } + control={ + onValueChange(event.currentTarget.value)} + placeholder={placeholder} + spellCheck={false} + value={value} + /> + } + /> + ); +} + export function GeneralSettingsPanel() { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 296944e113ac..abe1871f0c45 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -118,6 +118,17 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil ); } +/* The font tokens are declared outside the inline theme so utilities reference + the variables and Settings -> Appearance can override them at runtime. The + default stacks are mirrored in `appearanceFonts.ts`. */ +@theme { + --font-sans: + "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, + sans-serif; + --font-mono: + "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace; +} + @theme inline { --color-zinc-25: oklch(99.2% 0 0); --animate-skeleton: skeleton 2s -1s infinite linear; @@ -126,11 +137,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --animate-status-pulse: status-pulse 2s infinite; --animate-status-ping: status-ping 2s infinite; --animate-sidebar-working-text: sidebar-working-text 3.4s infinite; - --font-sans: - "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, - sans-serif; - --font-mono: - "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace; --color-warning-foreground: var(--warning-foreground); --color-warning: var(--warning); --color-success-foreground: var(--success-foreground); @@ -1049,6 +1055,13 @@ code { background: var(--app-scrollbar-thumb-hover); } +/* Settings -> Appearance can point the composer at its own face (for example a + mono font); default follows the sans stack. Applied on the surface wrapper so + the editor and its placeholder inherit together. */ +.composer-editor-surface { + font-family: var(--font-composer, var(--font-sans)); +} + .t3-ghostty-canvas { cursor: text; } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 346991d114de..99d5a2b60aa7 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -27,6 +27,7 @@ import { toastManager, } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; +import { applyAppearanceFontVariables } from "~/appearanceFonts"; import { useClientSettings } from "../hooks/useSettings"; import { deriveLogicalProjectKeyFromSettings, @@ -128,6 +129,7 @@ function RootRouteView() { + {primaryEnvironmentAuthenticated ? : null} @@ -152,6 +154,22 @@ function GlassAppearanceSync() { return null; } +function FontAppearanceSync() { + const fontFamilySans = useClientSettings((settings) => settings.fontFamilySans); + const fontFamilyCode = useClientSettings((settings) => settings.fontFamilyCode); + const fontFamilyComposer = useClientSettings((settings) => settings.fontFamilyComposer); + + useEffect(() => { + applyAppearanceFontVariables(document.documentElement, { + sans: fontFamilySans, + code: fontFamilyCode, + composer: fontFamilyComposer, + }); + }, [fontFamilyCode, fontFamilyComposer, fontFamilySans]); + + return null; +} + function DocumentTitleSync() { const primaryServerVersion = useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7edda2e52e5c..9efd0b3deb9a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -62,6 +62,13 @@ export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type; export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork"; +/** + * A user-chosen font family (a single name or a comma-separated list). Empty + * means "use the app default"; clients compose their own fallback stacks. + */ +export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200)); +export type FontFamilyPreference = typeof FontFamilyPreference.Type; + export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -76,6 +83,10 @@ export const ClientSettingsSchema = Schema.Struct({ glassOpacity: GlassOpacity.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_GLASS_OPACITY)), ), + fontFamilyCode: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + fontFamilyComposer: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + fontFamilySans: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + fontFamilyTerminal: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), // Model favorites. Historically keyed by provider kind, now // widened to `ProviderInstanceId` so users can favorite a specific model // on a custom provider instance (e.g. "Codex Personal · gpt-5") without @@ -680,6 +691,10 @@ export const ClientSettingsPatch = Schema.Struct({ diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), glassOpacity: Schema.optionalKey(GlassOpacity), + fontFamilyCode: Schema.optionalKey(FontFamilyPreference), + fontFamilyComposer: Schema.optionalKey(FontFamilyPreference), + fontFamilySans: Schema.optionalKey(FontFamilyPreference), + fontFamilyTerminal: Schema.optionalKey(FontFamilyPreference), favorites: Schema.optionalKey( Schema.Array( Schema.Struct({ From 3aaf1b36fcfe371d20038e707132f12fe54663a7 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:36:43 +0200 Subject: [PATCH 02/47] feat(web): font dropdowns with availability filtering and surface previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the free-text family inputs with dropdowns of curated faces filtered through document.fonts.check so only fonts that will actually render are offered (bundled webfonts always appear); a Custom… option keeps arbitrary families possible. Each option renders in its own face, and every row gains a preview card styled like the real surface — chat text, a mini composer, a highlighted code snippet, and a dark terminal prompt — rendered in the resolved stack, matching mobile's Appearance screen style. Co-Authored-By: Claude Fable 5 --- apps/web/src/appearanceFonts.ts | 56 +++++ .../components/settings/SettingsPanels.tsx | 227 ++++++++++++++---- 2 files changed, 242 insertions(+), 41 deletions(-) diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 16a36bb16451..4a4c15f9b38a 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -67,3 +67,59 @@ export function applyAppearanceFontVariables( } } } + +export interface FontOption { + readonly label: string; + readonly family: string; +} + +/** + * Curated choices for the Appearance dropdowns. The settings UI filters these + * through `isFontFamilyAvailable`, so platforms only offer faces that will + * actually render; "Custom" in the UI covers everything else. + */ +export const SANS_FONT_OPTIONS: readonly FontOption[] = [ + { label: "DM Sans", family: "DM Sans" }, + { label: "Inter", family: "Inter" }, + { label: "SF Pro", family: "SF Pro Text" }, + { label: "Segoe UI", family: "Segoe UI" }, + { label: "Roboto", family: "Roboto" }, + { label: "Helvetica Neue", family: "Helvetica Neue" }, + { label: "Arial", family: "Arial" }, + { label: "System UI", family: "system-ui" }, +]; + +export const MONO_FONT_OPTIONS: readonly FontOption[] = [ + { label: "SF Mono", family: "SF Mono" }, + { label: "JetBrains Mono", family: "JetBrains Mono" }, + { label: "Fira Code", family: "Fira Code" }, + { label: "Cascadia Code", family: "Cascadia Code" }, + { label: "Menlo", family: "Menlo" }, + { label: "Monaco", family: "Monaco" }, + { label: "Consolas", family: "Consolas" }, + { label: "Source Code Pro", family: "Source Code Pro" }, + { label: "IBM Plex Mono", family: "IBM Plex Mono" }, + { label: "Ubuntu Mono", family: "Ubuntu Mono" }, + { label: "Courier New", family: "Courier New" }, +]; + +export function isFontFamilyAvailable(family: string): boolean { + const families = cssFontFamilies(family); + if (families === null) return false; + // Generic keywords always resolve. + if (/^(system-ui|sans-serif|serif|monospace|ui-monospace)$/i.test(families)) return true; + try { + return document.fonts.check(`12px ${families}`); + } catch { + return false; + } +} + +/** Webfonts the app bundles; offered even before document.fonts has loaded them. */ +const BUNDLED_FAMILIES = new Set(["DM Sans", "JetBrains Mono"]); + +export function availableFontOptions(options: readonly FontOption[]): readonly FontOption[] { + return options.filter( + (option) => BUNDLED_FAMILIES.has(option.family) || isFontFamilyAvailable(option.family), + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d254150e2dbe..727c2b92a8fe 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -8,7 +8,7 @@ import { SettingsIcon, } from "lucide-react"; import { Link } from "@tanstack/react-router"; -import type { CSSProperties } from "react"; +import type { CSSProperties, ReactNode } from "react"; import { useCallback, useMemo, useRef, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { @@ -101,7 +101,11 @@ import { Input } from "../ui/input"; import { DEFAULT_CODE_FONT_STACK, DEFAULT_SANS_FONT_STACK, + MONO_FONT_OPTIONS, + SANS_FONT_OPTIONS, appearanceFontStack, + availableFontOptions, + type FontOption, } from "../../appearanceFonts"; import { NumberField, @@ -957,6 +961,19 @@ export function AppearanceSettingsPanel() { const { theme, setTheme } = useTheme(); const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); + const sansOptions = useMemo(() => availableFontOptions(SANS_FONT_OPTIONS), []); + const monoOptions = useMemo(() => availableFontOptions(MONO_FONT_OPTIONS), []); + const composerOptions = useMemo( + () => [...sansOptions, ...monoOptions], + [monoOptions, sansOptions], + ); + const sansStack = appearanceFontStack(settings.fontFamilySans, DEFAULT_SANS_FONT_STACK); + const composerStack = + settings.fontFamilyComposer.trim().length > 0 + ? appearanceFontStack(settings.fontFamilyComposer, DEFAULT_SANS_FONT_STACK) + : sansStack; + const codeStack = appearanceFontStack(settings.fontFamilyCode, DEFAULT_CODE_FONT_STACK); + const terminalStack = appearanceFontStack(settings.fontFamilyTerminal, DEFAULT_CODE_FONT_STACK); const environmentStageLabel = useEnvironmentStageLabel(); const showEnvironmentIdentification = resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null; @@ -1120,95 +1137,223 @@ export function AppearanceSettingsPanel() { updateSettings({ fontFamilySans })} + preview={ + +

+ The quick brown fox jumps over the lazy dog. +

+

+ Messages, labels, and headings across the app. +

+
+ } /> 0 - ? appearanceFontStack(settings.fontFamilyComposer, DEFAULT_SANS_FONT_STACK) - : appearanceFontStack(settings.fontFamilySans, DEFAULT_SANS_FONT_STACK) - } + options={composerOptions} + defaultOptionLabel="Same as interface font" value={settings.fontFamilyComposer} onValueChange={(fontFamilyComposer) => updateSettings({ fontFamilyComposer })} + preview={ + +
+

+ Fix the flaky test in surface.test.ts and explain the race. +

+

+ Ask for follow-up changes or attach images +

+
+
+ } /> updateSettings({ fontFamilyCode })} + preview={ + +
+                
+                  1
+                  {"  "}
+                  function{" "}
+                  formatUser
+                  (user) {"{"}
+                  {"\n"}
+                  2
+                  {"    "}
+                  return{" "}
+                  {"`${user.name} <${user.email}>`"}{" "}
+                  // 0O 1lI
+                  {"\n"}
+                  3
+                  {"  "}
+                  {"}"}
+                
+              
+
+ } /> updateSettings({ fontFamilyTerminal })} + preview={ + +
+                
+                  ${" "}
+                  npm run dev
+                  {"\n"}
+                  {"\u2713"} Ready in 430ms
+                  {"\n"}
+                  Local:{" "}
+                  http://localhost:3000
+                
+              
+
+ } /> ); } +const CUSTOM_FONT_VALUE = "__custom__"; +const DEFAULT_FONT_VALUE = "__default__"; + +function FontPreviewCard({ + children, + dark = false, + stack, +}: { + children: ReactNode; + dark?: boolean; + stack: string; +}) { + return ( +
+ {children} +
+ ); +} + function FontFamilySettingsRow({ title, description, - placeholder, - previewText, - previewStack, + options, + defaultOptionLabel = "Default", + preview, value, onValueChange, }: { title: string; description: string; - placeholder: string; - previewText: string; - previewStack: string; + options: readonly FontOption[]; + defaultOptionLabel?: string; + preview: ReactNode; value: string; onValueChange: (value: string) => void; }) { + const trimmed = value.trim(); + const matchesOption = options.some((option) => option.family === trimmed); + const [customMode, setCustomMode] = useState(trimmed.length > 0 && !matchesOption); + const selected = + trimmed.length === 0 && !customMode + ? DEFAULT_FONT_VALUE + : customMode || !matchesOption + ? CUSTOM_FONT_VALUE + : trimmed; return ( - {previewText} - - } resetAction={ - value.trim().length > 0 ? ( + trimmed.length > 0 || customMode ? ( onValueChange("")} + onClick={() => { + setCustomMode(false); + onValueChange(""); + }} /> ) : null } control={ - onValueChange(event.currentTarget.value)} - placeholder={placeholder} - spellCheck={false} - value={value} - /> +
+ + {customMode ? ( + onValueChange(event.currentTarget.value)} + placeholder="Font family name" + spellCheck={false} + value={value} + /> + ) : null} +
} - /> + > + {preview} +
); } From dc3ebeb386cc0cf528b0bb95e7a333eae1ab46e1 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:37:27 +0200 Subject: [PATCH 03/47] fix(web): make code and terminal font previews follow the selection The pre and code elements in the preview cards carried the preflight mono font-family, overriding the card's resolved stack, so those two previews never changed with the dropdown. They inherit now; also brace the comment-looking literal flagged by lint. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/settings/SettingsPanels.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 727c2b92a8fe..d4720eaf7917 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1179,8 +1179,11 @@ export function AppearanceSettingsPanel() { onValueChange={(fontFamilyCode) => updateSettings({ fontFamilyCode })} preview={ -
-                
+              
+                
                   1
                   {"  "}
                   function{" "}
@@ -1191,7 +1194,7 @@ export function AppearanceSettingsPanel() {
                   {"    "}
                   return{" "}
                   {"`${user.name} <${user.email}>`"}{" "}
-                  // 0O 1lI
+                  {"// 0O 1lI"}
                   {"\n"}
                   3
                   {"  "}
@@ -1209,8 +1212,8 @@ export function AppearanceSettingsPanel() {
           onValueChange={(fontFamilyTerminal) => updateSettings({ fontFamilyTerminal })}
           preview={
             
-              
-                
+              
+                
                   ${" "}
                   npm run dev
                   {"\n"}

From 80612c42021006e8bdd67b2b411653e2c423d59b Mon Sep 17 00:00:00 2001
From: Wout Stiens <71498452+StiensWout@users.noreply.github.com>
Date: Fri, 31 Jul 2026 13:40:35 +0200
Subject: [PATCH 04/47] fix(web): reliable font availability probing and
 theme-consistent previews
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

document.fonts.check() answers true for families that are not installed
(nothing needs loading), so the dropdowns offered fonts that silently
fell back to the default stack and the sans-side previews appeared not
to update. Availability now comes from canvas metric probing: a family
counts as available when substituting it before a generic changes the
measured advance.

The terminal preview card also follows the app theme now — the real
terminal renders on the app background in light mode, so a hardcoded
dark card was inconsistent theming.

Co-Authored-By: Claude Fable 5 
---
 apps/web/src/appearanceFonts.ts               | 27 ++++++++++++++++--
 .../components/settings/SettingsPanels.tsx    | 28 ++++++-------------
 2 files changed, 33 insertions(+), 22 deletions(-)

diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts
index 4a4c15f9b38a..1d6e3e9ac9ed 100644
--- a/apps/web/src/appearanceFonts.ts
+++ b/apps/web/src/appearanceFonts.ts
@@ -103,13 +103,36 @@ export const MONO_FONT_OPTIONS: readonly FontOption[] = [
   { label: "Courier New", family: "Courier New" },
 ];
 
+const FONT_PROBE_TEXT = "mmmmmmmmMMWli1O0@# fjord";
+let fontProbeContext: CanvasRenderingContext2D | null | undefined;
+
+function probeWidth(fontList: string): number | null {
+  if (fontProbeContext === undefined) {
+    fontProbeContext = document.createElement("canvas").getContext("2d");
+  }
+  if (fontProbeContext === null) return null;
+  fontProbeContext.font = `16px ${fontList}`;
+  return fontProbeContext.measureText(FONT_PROBE_TEXT).width;
+}
+
+/**
+ * Canvas metric probing instead of document.fonts.check(): check() reports
+ * true for families that are not installed at all (nothing needs loading), so
+ * it cannot filter the dropdown. A family exists when falling back to at
+ * least one generic changes the measured advance.
+ */
 export function isFontFamilyAvailable(family: string): boolean {
   const families = cssFontFamilies(family);
   if (families === null) return false;
-  // Generic keywords always resolve.
   if (/^(system-ui|sans-serif|serif|monospace|ui-monospace)$/i.test(families)) return true;
   try {
-    return document.fonts.check(`12px ${families}`);
+    for (const generic of ["monospace", "serif", "sans-serif"]) {
+      const baseline = probeWidth(generic);
+      const candidate = probeWidth(`${families}, ${generic}`);
+      if (baseline === null || candidate === null) return false;
+      if (candidate !== baseline) return true;
+    }
+    return false;
   } catch {
     return false;
   }
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index d4720eaf7917..ea85f161d9be 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -1211,16 +1211,16 @@ export function AppearanceSettingsPanel() {
           value={settings.fontFamilyTerminal}
           onValueChange={(fontFamilyTerminal) => updateSettings({ fontFamilyTerminal })}
           preview={
-            
+            
               
                 
-                  ${" "}
-                  npm run dev
+                  ${" "}
+                  npm run dev
                   {"\n"}
-                  {"\u2713"} Ready in 430ms
+                  {"\u2713"} Ready in 430ms
                   {"\n"}
-                  Local:{" "}
-                  http://localhost:3000
+                  Local:{" "}
+                  http://localhost:3000
                 
               
@@ -1234,23 +1234,11 @@ export function AppearanceSettingsPanel() { const CUSTOM_FONT_VALUE = "__custom__"; const DEFAULT_FONT_VALUE = "__default__"; -function FontPreviewCard({ - children, - dark = false, - stack, -}: { - children: ReactNode; - dark?: boolean; - stack: string; -}) { +function FontPreviewCard({ children, stack }: { children: ReactNode; stack: string }) { return (
{children} From 0d7b5f542a3ae6540b23349af61ccbd2532c18a1 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:48:06 +0200 Subject: [PATCH 05/47] test(desktop): add font-family keys to the client settings fixture Co-Authored-By: Claude Fable 5 --- apps/desktop/src/settings/DesktopClientSettings.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8d76ea83a33e..f41fe9834f05 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -20,6 +20,10 @@ const clientSettings: ClientSettings = { diffIgnoreWhitespace: true, environmentIdentificationMode: "artwork", favorites: [], + fontFamilyCode: "", + fontFamilyComposer: "", + fontFamilySans: "", + fontFamilyTerminal: "", glassOpacity: 80, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, From a31d2bc2de223e8ab1f2d82dd094bc89626f12b8 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:48:10 +0200 Subject: [PATCH 06/47] fix(web): route body and code font-family through the theme tokens The base styles hardcoded literal font stacks on body and pre/code, so the runtime --font-sans/--font-mono overrides from Settings -> Appearance only reached the preview cards. Reference the theme tokens instead; the literal stacks stay single-sourced in the @theme block. Co-Authored-By: Claude Fable 5 --- apps/web/src/index.css | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index abe1871f0c45..7cb45b6bc92e 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -962,14 +962,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } body { - font-family: - "DM Sans Variable", - "DM Sans", - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - system-ui, - sans-serif; + /* Reference the theme token (not a literal stack) so the Settings -> + Appearance runtime override of --font-sans reaches all interface text. */ + font-family: var(--font-sans); margin: 0; padding: 0; } @@ -1020,8 +1015,7 @@ body { pre, code { - font-family: - "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace; + font-family: var(--font-mono); } /* Window drag region (frameless titlebar) */ From d423e0034d894e7a743cbf68691711841836b5d0 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:06:41 +0200 Subject: [PATCH 07/47] fix(web): survive async settings hydration and group font dropdowns Client settings hydrate after mount, which dropped two things on reload: the terminal font (setFont fired while the surface was still loading, so the preference never reached the created terminal) and the custom-font input (customMode initialized from the pre-hydration empty value, leaving the field hidden behind a "Custom" trigger). The terminal re-applies the current preference once the surface exists, and the input visibility now derives from the value itself. Also groups mixed dropdowns (composer) into labeled Sans serif / Monospace sections and removes the composer's redundant default entry - the reset affordance already returns it to following the interface font. Co-Authored-By: Claude Fable 5 --- apps/web/src/appearanceFonts.ts | 37 ++++++++++++-- .../src/components/ThreadTerminalDrawer.tsx | 12 +++-- .../components/settings/SettingsPanels.tsx | 48 +++++++++++++++---- 3 files changed, 79 insertions(+), 18 deletions(-) diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 1d6e3e9ac9ed..8d10cba5340b 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -68,17 +68,28 @@ export function applyAppearanceFontVariables( } } +export type FontCategory = "Sans serif" | "Monospace"; + export interface FontOption { readonly label: string; readonly family: string; + readonly category: FontCategory; +} + +function fontCatalog( + category: FontCategory, + entries: ReadonlyArray>, +): readonly FontOption[] { + return entries.map((entry) => ({ ...entry, category })); } /** * Curated choices for the Appearance dropdowns. The settings UI filters these * through `isFontFamilyAvailable`, so platforms only offer faces that will - * actually render; "Custom" in the UI covers everything else. + * actually render; "Custom" in the UI covers everything else. The category + * groups mixed dropdowns (composer) into labeled sections. */ -export const SANS_FONT_OPTIONS: readonly FontOption[] = [ +export const SANS_FONT_OPTIONS: readonly FontOption[] = fontCatalog("Sans serif", [ { label: "DM Sans", family: "DM Sans" }, { label: "Inter", family: "Inter" }, { label: "SF Pro", family: "SF Pro Text" }, @@ -87,9 +98,9 @@ export const SANS_FONT_OPTIONS: readonly FontOption[] = [ { label: "Helvetica Neue", family: "Helvetica Neue" }, { label: "Arial", family: "Arial" }, { label: "System UI", family: "system-ui" }, -]; +]); -export const MONO_FONT_OPTIONS: readonly FontOption[] = [ +export const MONO_FONT_OPTIONS: readonly FontOption[] = fontCatalog("Monospace", [ { label: "SF Mono", family: "SF Mono" }, { label: "JetBrains Mono", family: "JetBrains Mono" }, { label: "Fira Code", family: "Fira Code" }, @@ -101,7 +112,23 @@ export const MONO_FONT_OPTIONS: readonly FontOption[] = [ { label: "IBM Plex Mono", family: "IBM Plex Mono" }, { label: "Ubuntu Mono", family: "Ubuntu Mono" }, { label: "Courier New", family: "Courier New" }, -]; +]); + +/** The options split into their labeled category sections, in catalog order. */ +export function fontOptionCategories( + options: readonly FontOption[], +): ReadonlyArray { + const sections = new Map(); + for (const option of options) { + const section = sections.get(option.category); + if (section === undefined) { + sections.set(option.category, [option]); + } else { + section.push(option); + } + } + return [...sections.entries()]; +} const FONT_PROBE_TEXT = "mmmmmmmmMMWli1O0@# fjord"; let fontProbeContext: CanvasRenderingContext2D | null | undefined; diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 401ad270712b..fcf11279ee32 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -388,11 +388,10 @@ export function TerminalViewport({ let setupCleanups: Array<() => void> = []; const setup = async (): Promise<(() => void) | null> => { + const setupFontFamily = terminalFontFamilyRef.current; const terminalOptions: GhosttyTerminalSurfaceOptions = { theme: terminalThemeFromApp(mount), - ...(terminalFontFamilyRef.current.length > 0 - ? { font: { family: terminalFontFamilyRef.current } } - : {}), + ...(setupFontFamily.length > 0 ? { font: { family: setupFontFamily } } : {}), onData: (data) => handleData(data), onResize: (cols, rows) => void resizeTerminal(cols, rows), onSelectionChange: () => handleSelectionChange(), @@ -410,6 +409,13 @@ export function TerminalViewport({ terminal.setTheme(terminalThemeFromApp(mount)); setupTerminal = terminal; terminalRef.current = terminal; + // Client settings hydrate asynchronously; a font preference that landed + // while the surface was loading found terminalRef null, so its setFont + // was dropped. Re-apply whatever is current once the terminal exists. + if (terminalFontFamilyRef.current !== setupFontFamily) { + const family = terminalFontFamilyRef.current.trim(); + void terminal.setFont(family.length > 0 ? { family } : {}); + } const latestSession = latestSessionRef.current; previousSessionRef.current = latestSession; if (latestSession.buffer.length > 0) terminal.resetAndWrite(latestSession.buffer); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index ea85f161d9be..3a2a8acb9863 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -105,6 +105,7 @@ import { SANS_FONT_OPTIONS, appearanceFontStack, availableFontOptions, + fontOptionCategories, type FontOption, } from "../../appearanceFonts"; import { @@ -114,7 +115,15 @@ import { NumberFieldIncrement, NumberFieldInput, } from "../ui/number-field"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { + Select, + SelectGroup, + SelectGroupLabel, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "../ui/select"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -1156,6 +1165,7 @@ export function AppearanceSettingsPanel() { description="Used in the message composer. Point it at a mono font if you prefer writing prompts in one." options={composerOptions} defaultOptionLabel="Same as interface font" + showDefaultOption={false} value={settings.fontFamilyComposer} onValueChange={(fontFamilyComposer) => updateSettings({ fontFamilyComposer })} preview={ @@ -1251,6 +1261,7 @@ function FontFamilySettingsRow({ description, options, defaultOptionLabel = "Default", + showDefaultOption = true, preview, value, onValueChange, @@ -1259,13 +1270,18 @@ function FontFamilySettingsRow({ description: string; options: readonly FontOption[]; defaultOptionLabel?: string; + showDefaultOption?: boolean; preview: ReactNode; value: string; onValueChange: (value: string) => void; }) { const trimmed = value.trim(); const matchesOption = options.some((option) => option.family === trimmed); - const [customMode, setCustomMode] = useState(trimmed.length > 0 && !matchesOption); + const [customMode, setCustomMode] = useState(false); + // Derived from the value, not just the picker state: client settings hydrate + // after mount, so a persisted custom family must reveal the input on its own. + const showCustomInput = customMode || (trimmed.length > 0 && !matchesOption); + const categories = fontOptionCategories(options); const selected = trimmed.length === 0 && !customMode ? DEFAULT_FONT_VALUE @@ -1316,25 +1332,37 @@ function FontFamilySettingsRow({ - - {defaultOptionLabel} - - {options.map((option) => ( - - {option.label} + {showDefaultOption ? ( + + {defaultOptionLabel} + ) : null} + {categories.map(([category, categoryOptions]) => ( + + {/* A lone section header is noise; label only mixed lists. */} + {categories.length > 1 ? {category} : null} + {categoryOptions.map((option) => ( + + {option.label} + + ))} + ))} Custom… - {customMode ? ( + {showCustomInput ? ( onValueChange(event.currentTarget.value)} + onChange={(event) => { + // Latch custom mode so clearing the text keeps the field open. + setCustomMode(true); + onValueChange(event.currentTarget.value); + }} placeholder="Font family name" spellCheck={false} value={value} From bec705950d80f8a322cddb257e23141716e430be Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:16:19 +0200 Subject: [PATCH 08/47] feat(web): unified font catalog per dropdown and draft-committed custom names Every font dropdown now offers the full catalog split into bold Sans serif / Monospace sections, so any surface can point at any face. The custom input edits a draft that only commits once the typed name probes as an installed font - the current font holds (and the field flags invalid) while a partial or unknown name is typed. Co-Authored-By: Claude Fable 5 --- .../components/settings/SettingsPanels.tsx | 47 ++++++++++++++----- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 3a2a8acb9863..91068cd91be9 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -106,6 +106,7 @@ import { appearanceFontStack, availableFontOptions, fontOptionCategories, + isFontFamilyAvailable, type FontOption, } from "../../appearanceFonts"; import { @@ -970,11 +971,11 @@ export function AppearanceSettingsPanel() { const { theme, setTheme } = useTheme(); const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); - const sansOptions = useMemo(() => availableFontOptions(SANS_FONT_OPTIONS), []); - const monoOptions = useMemo(() => availableFontOptions(MONO_FONT_OPTIONS), []); - const composerOptions = useMemo( - () => [...sansOptions, ...monoOptions], - [monoOptions, sansOptions], + // Every dropdown offers the full catalog, split into category sections, so + // any surface can point at any face - the categories carry the guidance. + const fontOptions = useMemo( + () => [...availableFontOptions(SANS_FONT_OPTIONS), ...availableFontOptions(MONO_FONT_OPTIONS)], + [], ); const sansStack = appearanceFontStack(settings.fontFamilySans, DEFAULT_SANS_FONT_STACK); const composerStack = @@ -1146,7 +1147,7 @@ export function AppearanceSettingsPanel() { updateSettings({ fontFamilySans })} preview={ @@ -1163,7 +1164,7 @@ export function AppearanceSettingsPanel() { updateSettings({ fontFamilyCode })} preview={ @@ -1217,7 +1218,7 @@ export function AppearanceSettingsPanel() { updateSettings({ fontFamilyTerminal })} preview={ @@ -1278,9 +1279,20 @@ function FontFamilySettingsRow({ const trimmed = value.trim(); const matchesOption = options.some((option) => option.family === trimmed); const [customMode, setCustomMode] = useState(false); + // The custom input edits a draft; the preference only commits once the text + // probes as an available font, so the current font holds while typing. + const [customDraft, setCustomDraft] = useState(value); + const lastValueRef = useRef(value); + if (lastValueRef.current !== value) { + // The committed value changed externally (hydration, reset); adopt it. + lastValueRef.current = value; + setCustomDraft(value); + } // Derived from the value, not just the picker state: client settings hydrate // after mount, so a persisted custom family must reveal the input on its own. const showCustomInput = customMode || (trimmed.length > 0 && !matchesOption); + const draftTrimmed = customDraft.trim(); + const draftPending = showCustomInput && draftTrimmed !== trimmed; const categories = fontOptionCategories(options); const selected = trimmed.length === 0 && !customMode @@ -1340,7 +1352,11 @@ function FontFamilySettingsRow({ {categories.map(([category, categoryOptions]) => ( {/* A lone section header is noise; label only mixed lists. */} - {categories.length > 1 ? {category} : null} + {categories.length > 1 ? ( + + {category} + + ) : null} {categoryOptions.map((option) => ( {option.label} @@ -1356,16 +1372,23 @@ function FontFamilySettingsRow({ {showCustomInput ? ( { + const next = event.currentTarget.value; // Latch custom mode so clearing the text keeps the field open. setCustomMode(true); - onValueChange(event.currentTarget.value); + setCustomDraft(next); + // Commit only names that resolve to an installed font; the + // current font holds while a partial or unknown name is typed. + if (next.trim().length > 0 && isFontFamilyAvailable(next)) { + onValueChange(next); + } }} placeholder="Font family name" spellCheck={false} - value={value} + value={customDraft} /> ) : null}
From 47b1e92d25ffd172679b083abb2c86795448c751 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:20:39 +0200 Subject: [PATCH 09/47] fix(web): route shadow-root code surfaces through the appearance font tokens The @pierre/diffs surfaces (file previews, chat diffs, annotatable code, search lines) render inside shadow roots with their own literal font stack as fallback, so the code font preference never reached them. The library consults --diffs-font-family/--diffs-header-font-family as override hooks and custom properties inherit across the shadow boundary, so defining the hooks once at :root covers every consumer. Co-Authored-By: Claude Fable 5 --- apps/web/src/index.css | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 7cb45b6bc92e..50d92dd20fd6 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1018,6 +1018,16 @@ code { font-family: var(--font-mono); } +/* @pierre/diffs surfaces (diffs, file previews, annotatable code, search + lines) render inside shadow roots but consult these hooks with their own + literal stacks as fallback. Custom properties inherit across the shadow + boundary, so defining them once here routes every code surface through the + appearance font tokens. */ +:root { + --diffs-font-family: var(--font-mono); + --diffs-header-font-family: var(--font-sans); +} + /* Window drag region (frameless titlebar) */ .drag-region { -webkit-app-region: drag; From df68214779b83c22581eecd027400d639b88837f Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:22:16 +0200 Subject: [PATCH 10/47] fix(web): include font settings in restore defaults and align composer preview Restore defaults now lists and resets the four font-family preferences (the General page's restore is intentionally global), and the composer preview falls back to the resolved interface stack so it matches the runtime var(--font-composer) chain instead of the bare default sans stack. Co-Authored-By: Claude Fable 5 --- .../components/settings/SettingsPanels.tsx | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 91068cd91be9..8b24dd92aa36 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -605,6 +605,16 @@ export function useSettingsRestore(onRestored?: () => void) { ? ["Project Grouping"] : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), + ...(settings.fontFamilySans !== DEFAULT_UNIFIED_SETTINGS.fontFamilySans + ? ["Interface font"] + : []), + ...(settings.fontFamilyComposer !== DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer + ? ["Composer font"] + : []), + ...(settings.fontFamilyCode !== DEFAULT_UNIFIED_SETTINGS.fontFamilyCode ? ["Code font"] : []), + ...(settings.fontFamilyTerminal !== DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal + ? ["Terminal font"] + : []), ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] : []), @@ -648,6 +658,10 @@ export function useSettingsRestore(onRestored?: () => void) { settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, settings.environmentIdentificationMode, + settings.fontFamilyCode, + settings.fontFamilyComposer, + settings.fontFamilySans, + settings.fontFamilyTerminal, settings.glassOpacity, settings.enableAssistantStreaming, settings.enableProviderUpdateChecks, @@ -691,6 +705,10 @@ export function useSettingsRestore(onRestored?: () => void) { confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, + fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, + fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer, + fontFamilyCode: DEFAULT_UNIFIED_SETTINGS.fontFamilyCode, + fontFamilyTerminal: DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal, }); onRestored?.(); }, [changedSettingLabels, onRestored, setTheme, updateSettings]); @@ -978,9 +996,11 @@ export function AppearanceSettingsPanel() { [], ); const sansStack = appearanceFontStack(settings.fontFamilySans, DEFAULT_SANS_FONT_STACK); + // The composer falls back to the resolved interface stack (not the bare + // default) so the preview matches the runtime var(--font-composer) chain. const composerStack = settings.fontFamilyComposer.trim().length > 0 - ? appearanceFontStack(settings.fontFamilyComposer, DEFAULT_SANS_FONT_STACK) + ? appearanceFontStack(settings.fontFamilyComposer, sansStack) : sansStack; const codeStack = appearanceFontStack(settings.fontFamilyCode, DEFAULT_CODE_FONT_STACK); const terminalStack = appearanceFontStack(settings.fontFamilyTerminal, DEFAULT_CODE_FONT_STACK); From 12182406906dd336aaaed6cde4d6ad3580517a39 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:38:37 +0200 Subject: [PATCH 11/47] refactor(web): scope font settings to the prompt textarea and terminal Per review direction the interface and code fonts stay on the theme defaults; only the prompt textarea (people who prefer writing prompts in a mono face) and the terminal remain configurable. The textarea setting is retitled to make its scope explicit - the rest of the composer follows the interface font. Co-Authored-By: Claude Fable 5 --- .../settings/DesktopClientSettings.test.ts | 2 - apps/web/src/appearanceFonts.ts | 22 ++---- .../components/settings/SettingsPanels.tsx | 73 +------------------ apps/web/src/index.css | 7 +- apps/web/src/routes/__root.tsx | 6 +- packages/contracts/src/settings.ts | 4 - 6 files changed, 17 insertions(+), 97 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index f41fe9834f05..a0330b2e3dde 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -20,9 +20,7 @@ const clientSettings: ClientSettings = { diffIgnoreWhitespace: true, environmentIdentificationMode: "artwork", favorites: [], - fontFamilyCode: "", fontFamilyComposer: "", - fontFamilySans: "", fontFamilyTerminal: "", glassOpacity: 80, providerModelPreferences: {}, diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 8d10cba5340b..3ddc575d18e4 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -40,31 +40,25 @@ export function appearanceFontStack(custom: string, defaultStack: string): strin } export interface AppearanceFontPreferences { - readonly sans: string; - readonly code: string; readonly composer: string; } /** * Apply the preferences to the root element. Unset preferences remove the * override so the stylesheet defaults (and theme changes) stay in charge. + * Only the prompt textarea (and, separately, the terminal surface) are + * configurable; the interface and code fonts stay on the theme defaults. */ export function applyAppearanceFontVariables( root: HTMLElement, preferences: AppearanceFontPreferences, ): void { - const assignments: ReadonlyArray = [ - ["--font-sans", cssFontFamilies(preferences.sans), DEFAULT_SANS_FONT_STACK], - ["--font-mono", cssFontFamilies(preferences.code), DEFAULT_CODE_FONT_STACK], - // The composer falls back to whatever the sans preference resolves to. - ["--font-composer", cssFontFamilies(preferences.composer), "var(--font-sans)"], - ]; - for (const [variable, families, defaultStack] of assignments) { - if (families === null) { - root.style.removeProperty(variable); - } else { - root.style.setProperty(variable, `${families}, ${defaultStack}`); - } + const families = cssFontFamilies(preferences.composer); + if (families === null) { + root.style.removeProperty("--font-composer"); + } else { + // The textarea falls back to the interface font when the custom faces miss. + root.style.setProperty("--font-composer", `${families}, var(--font-sans)`); } } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8b24dd92aa36..2111b0f30004 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -605,13 +605,9 @@ export function useSettingsRestore(onRestored?: () => void) { ? ["Project Grouping"] : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), - ...(settings.fontFamilySans !== DEFAULT_UNIFIED_SETTINGS.fontFamilySans - ? ["Interface font"] - : []), ...(settings.fontFamilyComposer !== DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer - ? ["Composer font"] + ? ["Prompt textarea font"] : []), - ...(settings.fontFamilyCode !== DEFAULT_UNIFIED_SETTINGS.fontFamilyCode ? ["Code font"] : []), ...(settings.fontFamilyTerminal !== DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal ? ["Terminal font"] : []), @@ -658,9 +654,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, settings.environmentIdentificationMode, - settings.fontFamilyCode, settings.fontFamilyComposer, - settings.fontFamilySans, settings.fontFamilyTerminal, settings.glassOpacity, settings.enableAssistantStreaming, @@ -705,9 +699,7 @@ export function useSettingsRestore(onRestored?: () => void) { confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, - fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer, - fontFamilyCode: DEFAULT_UNIFIED_SETTINGS.fontFamilyCode, fontFamilyTerminal: DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal, }); onRestored?.(); @@ -995,14 +987,7 @@ export function AppearanceSettingsPanel() { () => [...availableFontOptions(SANS_FONT_OPTIONS), ...availableFontOptions(MONO_FONT_OPTIONS)], [], ); - const sansStack = appearanceFontStack(settings.fontFamilySans, DEFAULT_SANS_FONT_STACK); - // The composer falls back to the resolved interface stack (not the bare - // default) so the preview matches the runtime var(--font-composer) chain. - const composerStack = - settings.fontFamilyComposer.trim().length > 0 - ? appearanceFontStack(settings.fontFamilyComposer, sansStack) - : sansStack; - const codeStack = appearanceFontStack(settings.fontFamilyCode, DEFAULT_CODE_FONT_STACK); + const composerStack = appearanceFontStack(settings.fontFamilyComposer, DEFAULT_SANS_FONT_STACK); const terminalStack = appearanceFontStack(settings.fontFamilyTerminal, DEFAULT_CODE_FONT_STACK); const environmentStageLabel = useEnvironmentStageLabel(); const showEnvironmentIdentification = @@ -1165,25 +1150,8 @@ export function AppearanceSettingsPanel() { updateSettings({ fontFamilySans })} - preview={ - -

- The quick brown fox jumps over the lazy dog. -

-

- Messages, labels, and headings across the app. -

-
- } - /> - } /> - updateSettings({ fontFamilyCode })} - preview={ - -
-                
-                  1
-                  {"  "}
-                  function{" "}
-                  formatUser
-                  (user) {"{"}
-                  {"\n"}
-                  2
-                  {"    "}
-                  return{" "}
-                  {"`${user.name} <${user.email}>`"}{" "}
-                  {"// 0O 1lI"}
-                  {"\n"}
-                  3
-                  {"  "}
-                  {"}"}
-                
-              
-
- } - /> Appearance can point the composer at its own face (for example a - mono font); default follows the sans stack. Applied on the surface wrapper so - the editor and its placeholder inherit together. */ +/* Settings -> Appearance can point the prompt textarea at its own face (for + example a mono font); default follows the sans stack. The wrapper holds only + the editable text and its placeholder, so the rest of the composer chrome + stays on the interface font. */ .composer-editor-surface { font-family: var(--font-composer, var(--font-sans)); } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 99d5a2b60aa7..7f3f7acb8bfe 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -155,17 +155,13 @@ function GlassAppearanceSync() { } function FontAppearanceSync() { - const fontFamilySans = useClientSettings((settings) => settings.fontFamilySans); - const fontFamilyCode = useClientSettings((settings) => settings.fontFamilyCode); const fontFamilyComposer = useClientSettings((settings) => settings.fontFamilyComposer); useEffect(() => { applyAppearanceFontVariables(document.documentElement, { - sans: fontFamilySans, - code: fontFamilyCode, composer: fontFamilyComposer, }); - }, [fontFamilyCode, fontFamilyComposer, fontFamilySans]); + }, [fontFamilyComposer]); return null; } diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9efd0b3deb9a..d1387ca3fa90 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -83,9 +83,7 @@ export const ClientSettingsSchema = Schema.Struct({ glassOpacity: GlassOpacity.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_GLASS_OPACITY)), ), - fontFamilyCode: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), fontFamilyComposer: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), - fontFamilySans: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), fontFamilyTerminal: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), // Model favorites. Historically keyed by provider kind, now // widened to `ProviderInstanceId` so users can favorite a specific model @@ -691,9 +689,7 @@ export const ClientSettingsPatch = Schema.Struct({ diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), glassOpacity: Schema.optionalKey(GlassOpacity), - fontFamilyCode: Schema.optionalKey(FontFamilyPreference), fontFamilyComposer: Schema.optionalKey(FontFamilyPreference), - fontFamilySans: Schema.optionalKey(FontFamilyPreference), fontFamilyTerminal: Schema.optionalKey(FontFamilyPreference), favorites: Schema.optionalKey( Schema.Array( From eadee770455ad51f2fd2a26c237218e1ae14b8b5 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:40:56 +0200 Subject: [PATCH 12/47] fix(web): commit an explicit clear of the custom font input An emptied custom font field now resets the preference to the default instead of silently keeping the previous font behind an empty-looking input; partial or unknown names still hold the current font while typing. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/settings/SettingsPanels.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 2111b0f30004..14bd429a1456 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1335,9 +1335,11 @@ function FontFamilySettingsRow({ // Latch custom mode so clearing the text keeps the field open. setCustomMode(true); setCustomDraft(next); - // Commit only names that resolve to an installed font; the - // current font holds while a partial or unknown name is typed. - if (next.trim().length > 0 && isFontFamilyAvailable(next)) { + // Commit names that resolve to an installed font - the current + // font holds while a partial or unknown name is typed - and + // commit an explicit full clear so the preference resets + // instead of silently keeping the previous font. + if (next.trim().length === 0 || isFontFamilyAvailable(next)) { onValueChange(next); } }} From 4e4cf0f4969fc6bc408487ff3cc036241a3be66a Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:45:42 +0200 Subject: [PATCH 13/47] fix(web): plainer copy for the font settings rows Co-Authored-By: Claude Fable 5 --- apps/web/src/components/settings/SettingsPanels.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 14bd429a1456..810318b804ef 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -606,7 +606,7 @@ export function useSettingsRestore(onRestored?: () => void) { : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), ...(settings.fontFamilyComposer !== DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer - ? ["Prompt textarea font"] + ? ["Prompt font"] : []), ...(settings.fontFamilyTerminal !== DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal ? ["Terminal font"] @@ -1150,10 +1150,10 @@ export function AppearanceSettingsPanel() { updateSettings({ fontFamilyComposer })} @@ -1172,7 +1172,7 @@ export function AppearanceSettingsPanel() { /> updateSettings({ fontFamilyTerminal })} From d932f98749c996662795ff7d83ecb9c534f37adc Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:35:04 +0200 Subject: [PATCH 14/47] feat(web): restore all four font settings with a calmer custom input The final split keeps every surface configurable: interface (labels, controls, messages, composer chrome), prompt (textarea and placeholder only), code (code blocks, diffs, file previews), and terminal. Every dropdown now offers the same Default entry, fixing the prompt/terminal inconsistency. The custom input no longer commits on every keystroke: it edits a local draft and commits 400ms after typing pauses (or on Enter/blur), so the app does not reflow mid-word and the unknown-name flag waits for the pause. Co-Authored-By: Claude Fable 5 --- .../settings/DesktopClientSettings.test.ts | 2 + apps/web/src/appearanceFonts.ts | 22 ++- .../components/settings/SettingsPanels.tsx | 138 +++++++++++++++--- apps/web/src/index.css | 7 +- apps/web/src/routes/__root.tsx | 6 +- packages/contracts/src/settings.ts | 4 + 6 files changed, 142 insertions(+), 37 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index a0330b2e3dde..f41fe9834f05 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -20,7 +20,9 @@ const clientSettings: ClientSettings = { diffIgnoreWhitespace: true, environmentIdentificationMode: "artwork", favorites: [], + fontFamilyCode: "", fontFamilyComposer: "", + fontFamilySans: "", fontFamilyTerminal: "", glassOpacity: 80, providerModelPreferences: {}, diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 3ddc575d18e4..8d10cba5340b 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -40,25 +40,31 @@ export function appearanceFontStack(custom: string, defaultStack: string): strin } export interface AppearanceFontPreferences { + readonly sans: string; + readonly code: string; readonly composer: string; } /** * Apply the preferences to the root element. Unset preferences remove the * override so the stylesheet defaults (and theme changes) stay in charge. - * Only the prompt textarea (and, separately, the terminal surface) are - * configurable; the interface and code fonts stay on the theme defaults. */ export function applyAppearanceFontVariables( root: HTMLElement, preferences: AppearanceFontPreferences, ): void { - const families = cssFontFamilies(preferences.composer); - if (families === null) { - root.style.removeProperty("--font-composer"); - } else { - // The textarea falls back to the interface font when the custom faces miss. - root.style.setProperty("--font-composer", `${families}, var(--font-sans)`); + const assignments: ReadonlyArray = [ + ["--font-sans", cssFontFamilies(preferences.sans), DEFAULT_SANS_FONT_STACK], + ["--font-mono", cssFontFamilies(preferences.code), DEFAULT_CODE_FONT_STACK], + // The composer falls back to whatever the sans preference resolves to. + ["--font-composer", cssFontFamilies(preferences.composer), "var(--font-sans)"], + ]; + for (const [variable, families, defaultStack] of assignments) { + if (families === null) { + root.style.removeProperty(variable); + } else { + root.style.setProperty(variable, `${families}, ${defaultStack}`); + } } } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 810318b804ef..6712feedf89e 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -9,7 +9,7 @@ import { } from "lucide-react"; import { Link } from "@tanstack/react-router"; import type { CSSProperties, ReactNode } from "react"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { defaultInstanceIdForDriver, @@ -605,9 +605,13 @@ export function useSettingsRestore(onRestored?: () => void) { ? ["Project Grouping"] : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), + ...(settings.fontFamilySans !== DEFAULT_UNIFIED_SETTINGS.fontFamilySans + ? ["Interface font"] + : []), ...(settings.fontFamilyComposer !== DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer ? ["Prompt font"] : []), + ...(settings.fontFamilyCode !== DEFAULT_UNIFIED_SETTINGS.fontFamilyCode ? ["Code font"] : []), ...(settings.fontFamilyTerminal !== DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal ? ["Terminal font"] : []), @@ -654,7 +658,9 @@ export function useSettingsRestore(onRestored?: () => void) { settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, settings.environmentIdentificationMode, + settings.fontFamilyCode, settings.fontFamilyComposer, + settings.fontFamilySans, settings.fontFamilyTerminal, settings.glassOpacity, settings.enableAssistantStreaming, @@ -699,7 +705,9 @@ export function useSettingsRestore(onRestored?: () => void) { confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, + fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer, + fontFamilyCode: DEFAULT_UNIFIED_SETTINGS.fontFamilyCode, fontFamilyTerminal: DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal, }); onRestored?.(); @@ -987,7 +995,14 @@ export function AppearanceSettingsPanel() { () => [...availableFontOptions(SANS_FONT_OPTIONS), ...availableFontOptions(MONO_FONT_OPTIONS)], [], ); - const composerStack = appearanceFontStack(settings.fontFamilyComposer, DEFAULT_SANS_FONT_STACK); + const sansStack = appearanceFontStack(settings.fontFamilySans, DEFAULT_SANS_FONT_STACK); + // The composer falls back to the resolved interface stack (not the bare + // default) so the preview matches the runtime var(--font-composer) chain. + const composerStack = + settings.fontFamilyComposer.trim().length > 0 + ? appearanceFontStack(settings.fontFamilyComposer, sansStack) + : sansStack; + const codeStack = appearanceFontStack(settings.fontFamilyCode, DEFAULT_CODE_FONT_STACK); const terminalStack = appearanceFontStack(settings.fontFamilyTerminal, DEFAULT_CODE_FONT_STACK); const environmentStageLabel = useEnvironmentStageLabel(); const showEnvironmentIdentification = @@ -1149,12 +1164,27 @@ export function AppearanceSettingsPanel() { + updateSettings({ fontFamilySans })} + preview={ + +

+ The quick brown fox jumps over the lazy dog. +

+

+ Messages, labels, and headings across the app. +

+
+ } + /> updateSettings({ fontFamilyComposer })} preview={ @@ -1170,6 +1200,39 @@ export function AppearanceSettingsPanel() {
} /> + updateSettings({ fontFamilyCode })} + preview={ + +
+                
+                  1
+                  {"  "}
+                  function{" "}
+                  formatUser
+                  (user) {"{"}
+                  {"\n"}
+                  2
+                  {"    "}
+                  return{" "}
+                  {"`${user.name} <${user.email}>`"}{" "}
+                  {"// 0O 1lI"}
+                  {"\n"}
+                  3
+                  {"  "}
+                  {"}"}
+                
+              
+
+ } + /> void; @@ -1234,20 +1293,48 @@ function FontFamilySettingsRow({ const trimmed = value.trim(); const matchesOption = options.some((option) => option.family === trimmed); const [customMode, setCustomMode] = useState(false); - // The custom input edits a draft; the preference only commits once the text - // probes as an available font, so the current font holds while typing. + // The custom input edits a draft; the preference only commits once typing + // pauses and the text probes as an available font (or is an explicit + // clear), so the current font holds and nothing reflows mid-word. const [customDraft, setCustomDraft] = useState(value); + const [draftSettled, setDraftSettled] = useState(true); + const commitTimerRef = useRef(null); const lastValueRef = useRef(value); if (lastValueRef.current !== value) { - // The committed value changed externally (hydration, reset); adopt it. + // The committed value changed externally (hydration, reset, dropdown + // pick); adopt it and drop any pending commit of a stale draft. lastValueRef.current = value; + if (commitTimerRef.current !== null) { + window.clearTimeout(commitTimerRef.current); + commitTimerRef.current = null; + } setCustomDraft(value); + setDraftSettled(true); } + useEffect( + () => () => { + if (commitTimerRef.current !== null) window.clearTimeout(commitTimerRef.current); + }, + [], + ); + const commitDraft = (next: string) => { + setDraftSettled(true); + if (next.trim().length === 0 || isFontFamilyAvailable(next)) { + onValueChange(next); + } + }; + const flushDraft = () => { + if (commitTimerRef.current === null) return; + window.clearTimeout(commitTimerRef.current); + commitTimerRef.current = null; + commitDraft(customDraft); + }; // Derived from the value, not just the picker state: client settings hydrate // after mount, so a persisted custom family must reveal the input on its own. const showCustomInput = customMode || (trimmed.length > 0 && !matchesOption); const draftTrimmed = customDraft.trim(); - const draftPending = showCustomInput && draftTrimmed !== trimmed; + // Flag an unknown name only once typing pauses, not on every keystroke. + const draftPending = draftSettled && showCustomInput && draftTrimmed !== trimmed; const categories = fontOptionCategories(options); const selected = trimmed.length === 0 && !customMode @@ -1292,18 +1379,16 @@ function FontFamilySettingsRow({ {selected === DEFAULT_FONT_VALUE - ? defaultOptionLabel + ? "Default" : selected === CUSTOM_FONT_VALUE ? "Custom" : (options.find((option) => option.family === selected)?.label ?? selected)} - {showDefaultOption ? ( - - {defaultOptionLabel} - - ) : null} + + Default + {categories.map(([category, categoryOptions]) => ( {/* A lone section header is noise; label only mixed lists. */} @@ -1330,18 +1415,23 @@ function FontFamilySettingsRow({ aria-invalid={draftPending || undefined} autoCapitalize="off" autoComplete="off" + onBlur={flushDraft} onChange={(event) => { const next = event.currentTarget.value; // Latch custom mode so clearing the text keeps the field open. setCustomMode(true); setCustomDraft(next); - // Commit names that resolve to an installed font - the current - // font holds while a partial or unknown name is typed - and - // commit an explicit full clear so the preference resets - // instead of silently keeping the previous font. - if (next.trim().length === 0 || isFontFamilyAvailable(next)) { - onValueChange(next); + setDraftSettled(false); + if (commitTimerRef.current !== null) { + window.clearTimeout(commitTimerRef.current); } + commitTimerRef.current = window.setTimeout(() => { + commitTimerRef.current = null; + commitDraft(next); + }, 400); + }} + onKeyDown={(event) => { + if (event.key === "Enter") flushDraft(); }} placeholder="Font family name" spellCheck={false} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 68a8de100970..50d92dd20fd6 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1059,10 +1059,9 @@ code { background: var(--app-scrollbar-thumb-hover); } -/* Settings -> Appearance can point the prompt textarea at its own face (for - example a mono font); default follows the sans stack. The wrapper holds only - the editable text and its placeholder, so the rest of the composer chrome - stays on the interface font. */ +/* Settings -> Appearance can point the composer at its own face (for example a + mono font); default follows the sans stack. Applied on the surface wrapper so + the editor and its placeholder inherit together. */ .composer-editor-surface { font-family: var(--font-composer, var(--font-sans)); } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 7f3f7acb8bfe..99d5a2b60aa7 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -155,13 +155,17 @@ function GlassAppearanceSync() { } function FontAppearanceSync() { + const fontFamilySans = useClientSettings((settings) => settings.fontFamilySans); + const fontFamilyCode = useClientSettings((settings) => settings.fontFamilyCode); const fontFamilyComposer = useClientSettings((settings) => settings.fontFamilyComposer); useEffect(() => { applyAppearanceFontVariables(document.documentElement, { + sans: fontFamilySans, + code: fontFamilyCode, composer: fontFamilyComposer, }); - }, [fontFamilyComposer]); + }, [fontFamilyCode, fontFamilyComposer, fontFamilySans]); return null; } diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index d1387ca3fa90..9efd0b3deb9a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -83,7 +83,9 @@ export const ClientSettingsSchema = Schema.Struct({ glassOpacity: GlassOpacity.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_GLASS_OPACITY)), ), + fontFamilyCode: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), fontFamilyComposer: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + fontFamilySans: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), fontFamilyTerminal: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), // Model favorites. Historically keyed by provider kind, now // widened to `ProviderInstanceId` so users can favorite a specific model @@ -689,7 +691,9 @@ export const ClientSettingsPatch = Schema.Struct({ diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), glassOpacity: Schema.optionalKey(GlassOpacity), + fontFamilyCode: Schema.optionalKey(FontFamilyPreference), fontFamilyComposer: Schema.optionalKey(FontFamilyPreference), + fontFamilySans: Schema.optionalKey(FontFamilyPreference), fontFamilyTerminal: Schema.optionalKey(FontFamilyPreference), favorites: Schema.optionalKey( Schema.Array( From 61df9652bb67adc4dbeb9746c8abb868c319d40d Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:12:42 +0200 Subject: [PATCH 15/47] fix(web): polish pass on font settings - Quote custom terminal families before they reach the canvas font string: a non-ident name ("3270 Nerd Font", "M+ 1m") made the whole string invalid CSS, so the assignment silently kept the previous font. - Point the DM Sans catalog entry at the registered "DM Sans Variable" family; the bundled webfont never registers plain "DM Sans", so the option rendered in the serif fallback and only applied by coincidence. - Cap the custom input at the schema's 200-character limit. - Cover fontOptionCategories and non-ident quoting with tests. Co-Authored-By: Claude Fable 5 --- apps/web/src/appearanceFonts.test.ts | 24 +++++++++++++++++++ apps/web/src/appearanceFonts.ts | 6 +++-- .../components/settings/SettingsPanels.tsx | 1 + apps/web/src/terminal/ghostty/surface.test.ts | 9 +++++++ apps/web/src/terminal/ghostty/surface.ts | 20 ++++++++++++++-- 5 files changed, 56 insertions(+), 4 deletions(-) diff --git a/apps/web/src/appearanceFonts.test.ts b/apps/web/src/appearanceFonts.test.ts index e25c0d0b150a..540a25bf8183 100644 --- a/apps/web/src/appearanceFonts.test.ts +++ b/apps/web/src/appearanceFonts.test.ts @@ -3,8 +3,11 @@ import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_CODE_FONT_STACK, DEFAULT_SANS_FONT_STACK, + MONO_FONT_OPTIONS, + SANS_FONT_OPTIONS, appearanceFontStack, cssFontFamilies, + fontOptionCategories, } from "./appearanceFonts"; describe("cssFontFamilies", () => { @@ -24,6 +27,27 @@ describe("cssFontFamilies", () => { expect(cssFontFamilies(" Fira Code , Menlo ")).toBe('"Fira Code", Menlo'); expect(cssFontFamilies('Bad"Name')).toBe('"BadName"'); }); + + it("quotes names that are not single CSS idents", () => { + expect(cssFontFamilies("3270 Nerd Font")).toBe('"3270 Nerd Font"'); + expect(cssFontFamilies("M+ 1m")).toBe('"M+ 1m"'); + }); +}); + +describe("fontOptionCategories", () => { + it("splits a mixed list into labeled sections preserving catalog order", () => { + const mixed = [...SANS_FONT_OPTIONS.slice(0, 2), ...MONO_FONT_OPTIONS.slice(0, 2)]; + const sections = fontOptionCategories(mixed); + expect(sections.map(([category]) => category)).toEqual(["Sans serif", "Monospace"]); + expect(sections[0]?.[1]).toEqual(SANS_FONT_OPTIONS.slice(0, 2)); + expect(sections[1]?.[1]).toEqual(MONO_FONT_OPTIONS.slice(0, 2)); + }); + + it("keeps a single-category list in one unlabeled-ready section", () => { + const sections = fontOptionCategories(MONO_FONT_OPTIONS); + expect(sections).toHaveLength(1); + expect(sections[0]?.[0]).toBe("Monospace"); + }); }); describe("appearanceFontStack", () => { diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 8d10cba5340b..01d6bebdad17 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -90,7 +90,9 @@ function fontCatalog( * groups mixed dropdowns (composer) into labeled sections. */ export const SANS_FONT_OPTIONS: readonly FontOption[] = fontCatalog("Sans serif", [ - { label: "DM Sans", family: "DM Sans" }, + // The bundled webfont registers as "DM Sans Variable", not "DM Sans"; the + // option must reference the registered name to resolve on every machine. + { label: "DM Sans", family: "DM Sans Variable" }, { label: "Inter", family: "Inter" }, { label: "SF Pro", family: "SF Pro Text" }, { label: "Segoe UI", family: "Segoe UI" }, @@ -166,7 +168,7 @@ export function isFontFamilyAvailable(family: string): boolean { } /** Webfonts the app bundles; offered even before document.fonts has loaded them. */ -const BUNDLED_FAMILIES = new Set(["DM Sans", "JetBrains Mono"]); +const BUNDLED_FAMILIES = new Set(["DM Sans Variable", "JetBrains Mono"]); export function availableFontOptions(options: readonly FontOption[]): readonly FontOption[] { return options.filter( diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 6712feedf89e..d5a5e3d88074 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1433,6 +1433,7 @@ function FontFamilySettingsRow({ onKeyDown={(event) => { if (event.key === "Enter") flushDraft(); }} + maxLength={200} placeholder="Font family name" spellCheck={false} value={customDraft} diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 1cb9502649bb..dbebf98f773f 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -232,6 +232,15 @@ describe("terminal font resolution", () => { expect(custom.endsWith("monospace")).toBe(true); }); + it("quotes families the canvas font shorthand would otherwise reject", () => { + expect(terminalFontFamily("3270 Nerd Font").startsWith('"3270 Nerd Font", ')).toBe(true); + expect(terminalFontFamily("M+ 1m").startsWith('"M+ 1m", ')).toBe(true); + expect(terminalFontFamily("Cascadia Code, Menlo").startsWith('"Cascadia Code", Menlo, ')).toBe( + true, + ); + expect(terminalFontFamily(" , ")).toBe(DEFAULT_TERMINAL_FONT_FAMILY); + }); + it("clamps requested font sizes to the supported range", () => { expect(terminalFontSize()).toBe(DEFAULT_TERMINAL_FONT_SIZE); expect(terminalFontSize(Number.NaN)).toBe(DEFAULT_TERMINAL_FONT_SIZE); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 86c1ad5329c1..62fbf60be0e3 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -59,9 +59,25 @@ function ensureTerminalSymbolsFont(): Promise { return symbolsFontLoad; } +function quoteTerminalFontFamilies(list: string): string { + return list + .split(",") + .map((name) => { + const bare = name.trim(); + if (bare.length === 0) return ""; + if (/^(['"]).*\1$/.test(bare)) return bare; + if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(bare)) return bare; + return `"${bare.replaceAll('"', "")}"`; + }) + .filter((name) => name.length > 0) + .join(", "); +} + export function terminalFontFamily(family?: string): string { - const custom = family?.trim(); - if (!custom) return DEFAULT_TERMINAL_FONT_FAMILY; + // Quote non-ident names ("3270 Nerd Font", "M+ 1m"): an unquoted one makes + // the whole canvas font string invalid and the assignment silently no-ops. + const custom = family === undefined ? "" : quoteTerminalFontFamilies(family); + if (custom.length === 0) return DEFAULT_TERMINAL_FONT_FAMILY; // A custom face keeps the glyph fallbacks so prompt symbols stay covered. return `${custom}, ${TERMINAL_GLYPH_FALLBACKS}`; } From 2e3547091a0938e350a1035e13f03632dbaf3ddc Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:47:49 +0200 Subject: [PATCH 16/47] fix(web): stop the custom-font shift and normalize apparent font size The custom input now replaces the dropdown instead of stacking beneath it, so entering custom mode no longer grows the row and pushes the rest of the page down; it autofocuses, and Escape discards uncommitted typing without closing the settings page. Typefaces differ in x-height at the same pixel size (DM Sans 0.51 vs JetBrains Mono 0.55), so switching family also changed apparent text size. Each surface now carries a font-size-adjust measured from its own default stack: the platform default renders unchanged, and any chosen font is scaled to match it. Measurements are re-taken once webfonts finish loading, since an early probe describes the fallback face rather than the default. The sidebar PR number follows the interface font (tabular digits keep it from reflowing) instead of the code font. Co-Authored-By: Claude Fable 5 --- apps/web/src/appearanceFonts.ts | 83 +++++- apps/web/src/components/SidebarV2.tsx | 4 +- .../components/settings/SettingsPanels.tsx | 258 ++++++++++++------ apps/web/src/index.css | 7 +- apps/web/src/routes/__root.tsx | 17 +- 5 files changed, 270 insertions(+), 99 deletions(-) diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 01d6bebdad17..09f3e38f6f16 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -48,26 +48,95 @@ export interface AppearanceFontPreferences { /** * Apply the preferences to the root element. Unset preferences remove the * override so the stylesheet defaults (and theme changes) stay in charge. + * + * Alongside each family we set a `font-size-adjust` value: typefaces differ in + * x-height at the same pixel size (DM Sans 0.51 vs JetBrains Mono 0.55), so a + * chosen font otherwise reads noticeably larger or smaller than the default. + * The adjust is the *default stack's* own ratio measured at runtime, which + * keeps the platform default rendering byte-identical (the ratio matches the + * face already in use) while scaling any chosen font to match it. */ export function applyAppearanceFontVariables( root: HTMLElement, preferences: AppearanceFontPreferences, ): void { - const assignments: ReadonlyArray = [ - ["--font-sans", cssFontFamilies(preferences.sans), DEFAULT_SANS_FONT_STACK], - ["--font-mono", cssFontFamilies(preferences.code), DEFAULT_CODE_FONT_STACK], + const assignments: ReadonlyArray< + readonly [family: string, adjust: string, custom: string, defaultStack: string] + > = [ + ["--font-sans", "--font-sans-adjust", preferences.sans, DEFAULT_SANS_FONT_STACK], + ["--font-mono", "--font-mono-adjust", preferences.code, DEFAULT_CODE_FONT_STACK], // The composer falls back to whatever the sans preference resolves to. - ["--font-composer", cssFontFamilies(preferences.composer), "var(--font-sans)"], + ["--font-composer", "--font-composer-adjust", preferences.composer, "var(--font-sans)"], ]; - for (const [variable, families, defaultStack] of assignments) { + for (const [variable, adjustVariable, custom, defaultStack] of assignments) { + const families = cssFontFamilies(custom); if (families === null) { root.style.removeProperty(variable); - } else { - root.style.setProperty(variable, `${families}, ${defaultStack}`); + root.style.removeProperty(adjustVariable); + continue; + } + root.style.setProperty(variable, `${families}, ${defaultStack}`); + // Normalize against the stock stack: the composer's nominal default is the + // sans variable, but that may itself be overridden (and already adjusted). + const reference = defaultStack === "var(--font-sans)" ? DEFAULT_SANS_FONT_STACK : defaultStack; + const ratio = fontXHeightRatio(reference); + root.style.setProperty(adjustVariable, ratio === null ? "none" : String(ratio)); + } +} + +const X_HEIGHT_PROBE_SIZE = 100; +const xHeightCache = new Map(); +let fontLoadInvalidationHooked = false; + +/** + * Measurements taken before the bundled webfonts finish loading describe the + * fallback face, not the real default (DM Sans 0.51 vs a generic 0.55), so + * drop the cache whenever a face finishes loading and let callers re-measure. + */ +function hookFontLoadInvalidation(): void { + if (fontLoadInvalidationHooked) return; + if (typeof document === "undefined" || document.fonts === undefined) return; + fontLoadInvalidationHooked = true; + document.fonts.addEventListener("loadingdone", () => { + xHeightCache.clear(); + }); +} + +/** + * The x-height of a font list as a fraction of its em size, or null when it + * cannot be measured (no canvas, or a font that reports no glyph bounds). + */ +export function fontXHeightRatio(fontList: string): number | null { + const families = cssFontFamilies(fontList); + if (families === null) return null; + hookFontLoadInvalidation(); + const cached = xHeightCache.get(families); + if (cached !== undefined) return cached; + try { + if (fontProbeContext === undefined) { + fontProbeContext = document.createElement("canvas").getContext("2d"); } + if (fontProbeContext === null) return null; + fontProbeContext.font = `${X_HEIGHT_PROBE_SIZE}px ${families}`; + const ascent = fontProbeContext.measureText("x").actualBoundingBoxAscent; + const ratio = + Number.isFinite(ascent) && ascent > 0 + ? Math.round((ascent / X_HEIGHT_PROBE_SIZE) * 1000) / 1000 + : null; + xHeightCache.set(families, ratio); + return ratio; + } catch { + return null; } } +/** Re-run `handler` whenever a font finishes loading and metrics may change. */ +export function subscribeToFontLoads(handler: () => void): () => void { + if (typeof document === "undefined" || document.fonts === undefined) return () => {}; + document.fonts.addEventListener("loadingdone", handler); + return () => document.fonts.removeEventListener("loadingdone", handler); +} + export type FontCategory = "Sans serif" | "Monospace"; export interface FontOption { diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index d58d2b37cd1d..82cfa9abac12 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -757,7 +757,9 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" onClick={handlePrClick} className={cn( - "shrink-0 font-mono text-xs hover:underline", + // Sidebar chrome follows the interface font; tabular digits keep the + // number from reflowing as PR states stream in. + "shrink-0 text-xs tabular-nums hover:underline", variant === "slim" && variantAction === "unsettle" ? props.isActive ? "text-muted-foreground/70" diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d5a5e3d88074..5d1a30859bbc 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2,6 +2,7 @@ import { ArchiveIcon, ArchiveX, InfoIcon, + ListIcon, LoaderIcon, PlusIcon, RefreshCwIcon, @@ -106,7 +107,9 @@ import { appearanceFontStack, availableFontOptions, fontOptionCategories, + fontXHeightRatio, isFontFamilyAvailable, + subscribeToFontLoads, type FontOption, } from "../../appearanceFonts"; import { @@ -1004,6 +1007,18 @@ export function AppearanceSettingsPanel() { : sansStack; const codeStack = appearanceFontStack(settings.fontFamilyCode, DEFAULT_CODE_FONT_STACK); const terminalStack = appearanceFontStack(settings.fontFamilyTerminal, DEFAULT_CODE_FONT_STACK); + // Referenced so the memoized adjusts re-resolve after webfonts load. + const fontMetricsEpoch = useFontMetricsEpoch(); + const sansAdjust = useMemo( + () => sizeAdjustFor(DEFAULT_SANS_FONT_STACK), + // eslint-disable-next-line react-hooks/exhaustive-deps -- epoch is the invalidation signal + [fontMetricsEpoch], + ); + const codeAdjust = useMemo( + () => sizeAdjustFor(DEFAULT_CODE_FONT_STACK), + // eslint-disable-next-line react-hooks/exhaustive-deps -- epoch is the invalidation signal + [fontMetricsEpoch], + ); const environmentStageLabel = useEnvironmentStageLabel(); const showEnvironmentIdentification = resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null; @@ -1171,7 +1186,7 @@ export function AppearanceSettingsPanel() { value={settings.fontFamilySans} onValueChange={(fontFamilySans) => updateSettings({ fontFamilySans })} preview={ - +

The quick brown fox jumps over the lazy dog.

@@ -1188,7 +1203,7 @@ export function AppearanceSettingsPanel() { value={settings.fontFamilyComposer} onValueChange={(fontFamilyComposer) => updateSettings({ fontFamilyComposer })} preview={ - +

Fix the flaky test in surface.test.ts and explain the race. @@ -1207,7 +1222,7 @@ export function AppearanceSettingsPanel() { value={settings.fontFamilyCode} onValueChange={(fontFamilyCode) => updateSettings({ fontFamilyCode })} preview={ - +

 updateSettings({ fontFamilyTerminal })}
           preview={
-            
+            
               
                 
                   ${" "}
@@ -1263,12 +1278,44 @@ export function AppearanceSettingsPanel() {
 const CUSTOM_FONT_VALUE = "__custom__";
 const DEFAULT_FONT_VALUE = "__default__";
 
-function FontPreviewCard({ children, stack }: { children: ReactNode; stack: string }) {
+/**
+ * Every font renders at the default stack's x-height, so switching family in a
+ * preview (or scanning the dropdown) shows the typeface changing without the
+ * apparent text size jumping with it. Mirrors the runtime `font-size-adjust`.
+ */
+function sizeAdjustFor(defaultStack: string): string {
+  const ratio = fontXHeightRatio(defaultStack);
+  return ratio === null ? "none" : String(ratio);
+}
+
+function optionSizeAdjust(): string {
+  return sizeAdjustFor(DEFAULT_SANS_FONT_STACK);
+}
+
+/**
+ * Re-render once webfonts load: metrics measured before then describe the
+ * fallback face, so the adjust values would be stale.
+ */
+function useFontMetricsEpoch(): number {
+  const [epoch, setEpoch] = useState(0);
+  useEffect(() => subscribeToFontLoads(() => setEpoch((value) => value + 1)), []);
+  return epoch;
+}
+
+function FontPreviewCard({
+  children,
+  stack,
+  adjust,
+}: {
+  children: ReactNode;
+  stack: string;
+  adjust: string;
+}) {
   return (
     
{children}
@@ -1358,87 +1405,130 @@ function FontFamilySettingsRow({ ) : null } control={ -
- + // The custom input replaces the dropdown rather than stacking under it, + // so entering custom mode does not change the row height. +
{showCustomInput ? ( - { - const next = event.currentTarget.value; - // Latch custom mode so clearing the text keeps the field open. - setCustomMode(true); - setCustomDraft(next); - setDraftSettled(false); - if (commitTimerRef.current !== null) { - window.clearTimeout(commitTimerRef.current); + <> + { + const next = event.currentTarget.value; + setCustomDraft(next); + setDraftSettled(false); + if (commitTimerRef.current !== null) { + window.clearTimeout(commitTimerRef.current); + } + commitTimerRef.current = window.setTimeout(() => { + commitTimerRef.current = null; + commitDraft(next); + }, 400); + }} + onKeyDown={(event) => { + if (event.key === "Enter") flushDraft(); + if (event.key === "Escape") { + // Discard uncommitted typing without leaving the settings + // page (Escape closes it), and drop back to the list only + // when there is no committed family to return to. + event.preventDefault(); + event.stopPropagation(); + if (commitTimerRef.current !== null) { + window.clearTimeout(commitTimerRef.current); + commitTimerRef.current = null; + } + setCustomDraft(value); + setDraftSettled(true); + if (trimmed.length === 0) setCustomMode(false); + } + }} + placeholder="Font family name" + spellCheck={false} + value={customDraft} + /> + + { + setCustomMode(false); + onValueChange(""); + }} + size="icon-sm" + variant="ghost" + > + + + } + /> + Choose from the list + + + ) : ( + + )}
} > diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 50d92dd20fd6..c0206faef7b3 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -963,8 +963,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil body { /* Reference the theme token (not a literal stack) so the Settings -> - Appearance runtime override of --font-sans reaches all interface text. */ + Appearance runtime override of --font-sans reaches all interface text. + The adjust normalizes a chosen font's x-height to the default stack's, so + switching family does not also change apparent text size. */ font-family: var(--font-sans); + font-size-adjust: var(--font-sans-adjust, none); margin: 0; padding: 0; } @@ -1016,6 +1019,7 @@ body { pre, code { font-family: var(--font-mono); + font-size-adjust: var(--font-mono-adjust, none); } /* @pierre/diffs surfaces (diffs, file previews, annotatable code, search @@ -1064,6 +1068,7 @@ code { the editor and its placeholder inherit together. */ .composer-editor-surface { font-family: var(--font-composer, var(--font-sans)); + font-size-adjust: var(--font-composer-adjust, var(--font-sans-adjust, none)); } .t3-ghostty-canvas { diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 99d5a2b60aa7..8aa6c3e7f864 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -27,7 +27,7 @@ import { toastManager, } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; -import { applyAppearanceFontVariables } from "~/appearanceFonts"; +import { applyAppearanceFontVariables, subscribeToFontLoads } from "~/appearanceFonts"; import { useClientSettings } from "../hooks/useSettings"; import { deriveLogicalProjectKeyFromSettings, @@ -160,11 +160,16 @@ function FontAppearanceSync() { const fontFamilyComposer = useClientSettings((settings) => settings.fontFamilyComposer); useEffect(() => { - applyAppearanceFontVariables(document.documentElement, { - sans: fontFamilySans, - code: fontFamilyCode, - composer: fontFamilyComposer, - }); + const apply = () => + applyAppearanceFontVariables(document.documentElement, { + sans: fontFamilySans, + code: fontFamilyCode, + composer: fontFamilyComposer, + }); + apply(); + // The size adjust is measured from the default stack; a webfont that loads + // after the first pass changes that measurement, so re-apply once settled. + return subscribeToFontLoads(apply); }, [fontFamilyCode, fontFamilyComposer, fontFamilySans]); return null; From 9001d78a25327679d118d592e3173912ae2a7d94 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:49:57 +0200 Subject: [PATCH 17/47] fix(web): start the custom font field empty Selecting Custom no longer prefills the outgoing family, so the field is ready to type into; the applied font holds until a valid name is entered, and an empty field is not flagged as invalid. Co-Authored-By: Claude Fable 5 --- .../web/src/components/settings/SettingsPanels.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 5d1a30859bbc..60027b939f20 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1380,8 +1380,10 @@ function FontFamilySettingsRow({ // after mount, so a persisted custom family must reveal the input on its own. const showCustomInput = customMode || (trimmed.length > 0 && !matchesOption); const draftTrimmed = customDraft.trim(); - // Flag an unknown name only once typing pauses, not on every keystroke. - const draftPending = draftSettled && showCustomInput && draftTrimmed !== trimmed; + // Flag an unknown name only once typing pauses, and never for an empty + // field - that is the starting state, not a rejected entry. + const draftPending = + draftSettled && showCustomInput && draftTrimmed.length > 0 && draftTrimmed !== trimmed; const categories = fontOptionCategories(options); const selected = trimmed.length === 0 && !customMode @@ -1483,6 +1485,14 @@ function FontFamilySettingsRow({ return; } if (next === CUSTOM_FONT_VALUE) { + // Start from an empty field rather than the outgoing family; + // the applied font holds until a valid name is entered. + if (commitTimerRef.current !== null) { + window.clearTimeout(commitTimerRef.current); + commitTimerRef.current = null; + } + setCustomDraft(""); + setDraftSettled(true); setCustomMode(true); return; } From 3bafffde274ead62f32a147af87c1940da55bd0c Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:58:11 +0200 Subject: [PATCH 18/47] fix(web): plainer font setting descriptions Four rows all opening with "Used ..." read like a filled-in template and restated their own titles. Each line now just names what it covers, and the prompt row carries the reason people actually asked for it. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/settings/SettingsPanels.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 60027b939f20..8547afd4a43b 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1181,7 +1181,7 @@ export function AppearanceSettingsPanel() { updateSettings({ fontFamilySans })} @@ -1198,7 +1198,7 @@ export function AppearanceSettingsPanel() { /> updateSettings({ fontFamilyComposer })} @@ -1217,7 +1217,7 @@ export function AppearanceSettingsPanel() { /> updateSettings({ fontFamilyCode })} @@ -1250,7 +1250,7 @@ export function AppearanceSettingsPanel() { /> updateSettings({ fontFamilyTerminal })} From 1a3481aa90cb3c0a76aa9a5b67f401c8f33164da Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:03:57 +0200 Subject: [PATCH 19/47] fix(web): cap the size correction for faces with unusual proportions Matching x-heights exactly overcorrects: real Courier New sits at ~0.42 em against DM Sans' 0.51, so an exact match scaled it 21% up and its capitals and ascenders towered over everything else. The correction is now capped at 6%, and each preview normalizes its own stack rather than a shared default so it shows the size the app will actually render. Co-Authored-By: Claude Fable 5 --- apps/web/src/appearanceFonts.test.ts | 25 ++++++++++++ apps/web/src/appearanceFonts.ts | 34 ++++++++++++++++- .../components/settings/SettingsPanels.tsx | 38 +++++++++++-------- 3 files changed, 79 insertions(+), 18 deletions(-) diff --git a/apps/web/src/appearanceFonts.test.ts b/apps/web/src/appearanceFonts.test.ts index 540a25bf8183..a3c50bf67139 100644 --- a/apps/web/src/appearanceFonts.test.ts +++ b/apps/web/src/appearanceFonts.test.ts @@ -5,6 +5,7 @@ import { DEFAULT_SANS_FONT_STACK, MONO_FONT_OPTIONS, SANS_FONT_OPTIONS, + clampSizeAdjust, appearanceFontStack, cssFontFamilies, fontOptionCategories, @@ -34,6 +35,30 @@ describe("cssFontFamilies", () => { }); }); +describe("clampSizeAdjust", () => { + it("matches the target when the face is close to it", () => { + // Arial 0.54 against a 0.51 target: a 5.6% correction is within range. + expect(clampSizeAdjust(0.51, 0.54)).toBe(0.51); + }); + + it("caps the correction for faces with an unusual x-height", () => { + // Courier New (~0.42) would scale 21% up to reach 0.51; cap it at 6%. + const adjust = clampSizeAdjust(0.51, 0.42); + expect(adjust).toBe(0.445); + expect(adjust / 0.42).toBeLessThanOrEqual(1.061); + }); + + it("caps oversized faces symmetrically", () => { + const adjust = clampSizeAdjust(0.42, 0.55); + expect(adjust).toBe(0.517); + expect(adjust / 0.55).toBeGreaterThanOrEqual(0.939); + }); + + it("leaves a face that already matches untouched", () => { + expect(clampSizeAdjust(0.51, 0.51)).toBe(0.51); + }); +}); + describe("fontOptionCategories", () => { it("splits a mixed list into labeled sections preserving catalog order", () => { const mixed = [...SANS_FONT_OPTIONS.slice(0, 2), ...MONO_FONT_OPTIONS.slice(0, 2)]; diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 09f3e38f6f16..194b3bc8daec 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -79,8 +79,10 @@ export function applyAppearanceFontVariables( // Normalize against the stock stack: the composer's nominal default is the // sans variable, but that may itself be overridden (and already adjusted). const reference = defaultStack === "var(--font-sans)" ? DEFAULT_SANS_FONT_STACK : defaultStack; - const ratio = fontXHeightRatio(reference); - root.style.setProperty(adjustVariable, ratio === null ? "none" : String(ratio)); + root.style.setProperty( + adjustVariable, + fontSizeAdjustValue(`${families}, ${reference}`, reference), + ); } } @@ -130,6 +132,34 @@ export function fontXHeightRatio(fontList: string): number | null { } } +/** + * Matching x-heights exactly is too aggressive for faces with unusual + * proportions: Courier New's x-height is ~0.42 em against DM Sans' ~0.51, so + * an exact match scales it 21% up and its capitals and ascenders tower. Cap + * the correction so a font still reads at a comparable size without the rest + * of its design being blown out of proportion. + */ +const MIN_SIZE_ADJUST_SCALE = 0.94; +const MAX_SIZE_ADJUST_SCALE = 1.06; + +export function clampSizeAdjust(targetRatio: number, actualRatio: number): number { + const lower = actualRatio * MIN_SIZE_ADJUST_SCALE; + const upper = actualRatio * MAX_SIZE_ADJUST_SCALE; + return Math.round(Math.min(Math.max(targetRatio, lower), upper) * 1000) / 1000; +} + +/** + * The `font-size-adjust` value that brings `fontList` closest to the apparent + * size of `defaultStack`, or "none" when either cannot be measured. Identical + * stacks resolve to the default's own ratio, leaving rendering untouched. + */ +export function fontSizeAdjustValue(fontList: string, defaultStack: string): string { + const target = fontXHeightRatio(defaultStack); + const actual = fontXHeightRatio(fontList); + if (target === null || actual === null || actual <= 0) return "none"; + return String(clampSizeAdjust(target, actual)); +} + /** Re-run `handler` whenever a font finishes loading and metrics may change. */ export function subscribeToFontLoads(handler: () => void): () => void { if (typeof document === "undefined" || document.fonts === undefined) return () => {}; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8547afd4a43b..69994d0cc214 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -107,7 +107,7 @@ import { appearanceFontStack, availableFontOptions, fontOptionCategories, - fontXHeightRatio, + fontSizeAdjustValue, isFontFamilyAvailable, subscribeToFontLoads, type FontOption, @@ -1007,17 +1007,28 @@ export function AppearanceSettingsPanel() { : sansStack; const codeStack = appearanceFontStack(settings.fontFamilyCode, DEFAULT_CODE_FONT_STACK); const terminalStack = appearanceFontStack(settings.fontFamilyTerminal, DEFAULT_CODE_FONT_STACK); - // Referenced so the memoized adjusts re-resolve after webfonts load. + // Each preview normalizes its own stack, so it shows exactly the size the + // app will render. The epoch re-resolves them once webfonts have loaded. const fontMetricsEpoch = useFontMetricsEpoch(); const sansAdjust = useMemo( - () => sizeAdjustFor(DEFAULT_SANS_FONT_STACK), + () => fontSizeAdjustValue(sansStack, DEFAULT_SANS_FONT_STACK), // eslint-disable-next-line react-hooks/exhaustive-deps -- epoch is the invalidation signal - [fontMetricsEpoch], + [sansStack, fontMetricsEpoch], + ); + const composerAdjust = useMemo( + () => fontSizeAdjustValue(composerStack, DEFAULT_SANS_FONT_STACK), + // eslint-disable-next-line react-hooks/exhaustive-deps -- epoch is the invalidation signal + [composerStack, fontMetricsEpoch], ); const codeAdjust = useMemo( - () => sizeAdjustFor(DEFAULT_CODE_FONT_STACK), + () => fontSizeAdjustValue(codeStack, DEFAULT_CODE_FONT_STACK), + // eslint-disable-next-line react-hooks/exhaustive-deps -- epoch is the invalidation signal + [codeStack, fontMetricsEpoch], + ); + const terminalAdjust = useMemo( + () => fontSizeAdjustValue(terminalStack, DEFAULT_CODE_FONT_STACK), // eslint-disable-next-line react-hooks/exhaustive-deps -- epoch is the invalidation signal - [fontMetricsEpoch], + [terminalStack, fontMetricsEpoch], ); const environmentStageLabel = useEnvironmentStageLabel(); const showEnvironmentIdentification = @@ -1203,7 +1214,7 @@ export function AppearanceSettingsPanel() { value={settings.fontFamilyComposer} onValueChange={(fontFamilyComposer) => updateSettings({ fontFamilyComposer })} preview={ - +

Fix the flaky test in surface.test.ts and explain the race. @@ -1255,7 +1266,7 @@ export function AppearanceSettingsPanel() { value={settings.fontFamilyTerminal} onValueChange={(fontFamilyTerminal) => updateSettings({ fontFamilyTerminal })} preview={ - +

                 
                   ${" "}
@@ -1283,13 +1294,8 @@ const DEFAULT_FONT_VALUE = "__default__";
  * preview (or scanning the dropdown) shows the typeface changing without the
  * apparent text size jumping with it. Mirrors the runtime `font-size-adjust`.
  */
-function sizeAdjustFor(defaultStack: string): string {
-  const ratio = fontXHeightRatio(defaultStack);
-  return ratio === null ? "none" : String(ratio);
-}
-
-function optionSizeAdjust(): string {
-  return sizeAdjustFor(DEFAULT_SANS_FONT_STACK);
+function optionSizeAdjust(family: string): string {
+  return fontSizeAdjustValue(family, DEFAULT_SANS_FONT_STACK);
 }
 
 /**
@@ -1524,7 +1530,7 @@ function FontFamilySettingsRow({
                         
                           {option.label}

From ddb3b48c1db8784809239bc800094d1c6e7c27e4 Mon Sep 17 00:00:00 2001
From: Wout Stiens <71498452+StiensWout@users.noreply.github.com>
Date: Sat, 1 Aug 2026 09:04:18 +0200
Subject: [PATCH 20/47] docs(web): correct the size-adjust docblock after
 clamping

Co-Authored-By: Claude Fable 5 
---
 apps/web/src/appearanceFonts.ts | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts
index 194b3bc8daec..2763e516f5b8 100644
--- a/apps/web/src/appearanceFonts.ts
+++ b/apps/web/src/appearanceFonts.ts
@@ -52,9 +52,9 @@ export interface AppearanceFontPreferences {
  * Alongside each family we set a `font-size-adjust` value: typefaces differ in
  * x-height at the same pixel size (DM Sans 0.51 vs JetBrains Mono 0.55), so a
  * chosen font otherwise reads noticeably larger or smaller than the default.
- * The adjust is the *default stack's* own ratio measured at runtime, which
- * keeps the platform default rendering byte-identical (the ratio matches the
- * face already in use) while scaling any chosen font to match it.
+ * The value is measured at runtime against the default stack and capped by
+ * `clampSizeAdjust`; an unset preference removes it entirely, so the platform
+ * default always renders exactly as it did before.
  */
 export function applyAppearanceFontVariables(
   root: HTMLElement,

From 04c529a022b60564ca3714e22d38569f529762a3 Mon Sep 17 00:00:00 2001
From: Wout Stiens <71498452+StiensWout@users.noreply.github.com>
Date: Sat, 1 Aug 2026 09:24:34 +0200
Subject: [PATCH 21/47] feat(web): font size sliders instead of automatic size
 normalization

Replaces the font-size-adjust correction with explicit control, mirroring
the mobile Appearance sliders: interface, prompt, code, and terminal each
get a size in CSS pixels alongside their family.

The x-height correction could never satisfy every face - matching exactly
blew out Courier New's proportions, capping it left the difference visible -
so the size is the user's call now. Ranges are clamped in the schema (the
interface size drives the root font size, and with it every rem-based
dimension) and each preview renders at the size its surface will use.

Co-Authored-By: Claude Fable 5 
---
 .../settings/DesktopClientSettings.test.ts    |   4 +
 apps/web/src/appearanceFonts.test.ts          |  44 +-
 apps/web/src/appearanceFonts.ts               | 148 +++----
 .../src/components/ComposerPromptEditor.tsx   |   6 +-
 .../src/components/ThreadTerminalDrawer.tsx   |  29 +-
 .../components/settings/SettingsPanels.tsx    | 410 ++++++++++--------
 apps/web/src/index.css                        |  68 +--
 apps/web/src/routes/__root.tsx                |  32 +-
 packages/contracts/src/settings.ts            |  53 +++
 9 files changed, 428 insertions(+), 366 deletions(-)

diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts
index f41fe9834f05..9988e500ebe2 100644
--- a/apps/desktop/src/settings/DesktopClientSettings.test.ts
+++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts
@@ -24,6 +24,10 @@ const clientSettings: ClientSettings = {
   fontFamilyComposer: "",
   fontFamilySans: "",
   fontFamilyTerminal: "",
+  fontSizeCode: 13,
+  fontSizeInterface: 16,
+  fontSizePrompt: 14,
+  fontSizeTerminal: 12,
   glassOpacity: 80,
   providerModelPreferences: {},
   sidebarAutoSettleAfterDays: 3,
diff --git a/apps/web/src/appearanceFonts.test.ts b/apps/web/src/appearanceFonts.test.ts
index a3c50bf67139..616ab7bd86a2 100644
--- a/apps/web/src/appearanceFonts.test.ts
+++ b/apps/web/src/appearanceFonts.test.ts
@@ -1,11 +1,13 @@
 import { describe, expect, it } from "vite-plus/test";
 
 import {
+  clampCodeFontSize,
+  clampInterfaceFontSize,
+  clampPromptFontSize,
   DEFAULT_CODE_FONT_STACK,
   DEFAULT_SANS_FONT_STACK,
   MONO_FONT_OPTIONS,
   SANS_FONT_OPTIONS,
-  clampSizeAdjust,
   appearanceFontStack,
   cssFontFamilies,
   fontOptionCategories,
@@ -35,30 +37,6 @@ describe("cssFontFamilies", () => {
   });
 });
 
-describe("clampSizeAdjust", () => {
-  it("matches the target when the face is close to it", () => {
-    // Arial 0.54 against a 0.51 target: a 5.6% correction is within range.
-    expect(clampSizeAdjust(0.51, 0.54)).toBe(0.51);
-  });
-
-  it("caps the correction for faces with an unusual x-height", () => {
-    // Courier New (~0.42) would scale 21% up to reach 0.51; cap it at 6%.
-    const adjust = clampSizeAdjust(0.51, 0.42);
-    expect(adjust).toBe(0.445);
-    expect(adjust / 0.42).toBeLessThanOrEqual(1.061);
-  });
-
-  it("caps oversized faces symmetrically", () => {
-    const adjust = clampSizeAdjust(0.42, 0.55);
-    expect(adjust).toBe(0.517);
-    expect(adjust / 0.55).toBeGreaterThanOrEqual(0.939);
-  });
-
-  it("leaves a face that already matches untouched", () => {
-    expect(clampSizeAdjust(0.51, 0.51)).toBe(0.51);
-  });
-});
-
 describe("fontOptionCategories", () => {
   it("splits a mixed list into labeled sections preserving catalog order", () => {
     const mixed = [...SANS_FONT_OPTIONS.slice(0, 2), ...MONO_FONT_OPTIONS.slice(0, 2)];
@@ -86,3 +64,19 @@ describe("appearanceFontStack", () => {
     expect(appearanceFontStack("", DEFAULT_SANS_FONT_STACK)).toBe(DEFAULT_SANS_FONT_STACK);
   });
 });
+
+describe("font size clamping", () => {
+  it("keeps sizes inside the ranges the UI can absorb", () => {
+    expect(clampInterfaceFontSize(16)).toBe(16);
+    expect(clampInterfaceFontSize(2)).toBe(12);
+    expect(clampInterfaceFontSize(96)).toBe(20);
+    expect(clampPromptFontSize(40)).toBe(20);
+    expect(clampCodeFontSize(1)).toBe(10);
+  });
+
+  it("rounds fractional values and falls back for unusable input", () => {
+    expect(clampCodeFontSize(13.4)).toBe(13);
+    expect(clampInterfaceFontSize(Number.NaN)).toBe(16);
+    expect(clampPromptFontSize(Number.POSITIVE_INFINITY)).toBe(14);
+  });
+});
diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts
index 2763e516f5b8..f2d12c5799e1 100644
--- a/apps/web/src/appearanceFonts.ts
+++ b/apps/web/src/appearanceFonts.ts
@@ -1,10 +1,22 @@
 /**
- * Font-family preferences from Settings → Appearance, applied as CSS custom
+ * Font preferences from Settings → Appearance, applied as CSS custom
  * properties. The default stacks mirror the `--font-sans` / `--font-mono`
  * definitions in `index.css`; a custom family is always prepended to the
  * matching default stack so glyph coverage never regresses.
  */
 
+import {
+  DEFAULT_CODE_FONT_SIZE,
+  DEFAULT_INTERFACE_FONT_SIZE,
+  DEFAULT_PROMPT_FONT_SIZE,
+  MAX_CODE_FONT_SIZE,
+  MAX_INTERFACE_FONT_SIZE,
+  MAX_PROMPT_FONT_SIZE,
+  MIN_CODE_FONT_SIZE,
+  MIN_INTERFACE_FONT_SIZE,
+  MIN_PROMPT_FONT_SIZE,
+} from "@t3tools/contracts";
+
 export const DEFAULT_SANS_FONT_STACK =
   '"DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, ' +
   "sans-serif";
@@ -43,128 +55,66 @@ export interface AppearanceFontPreferences {
   readonly sans: string;
   readonly code: string;
   readonly composer: string;
+  readonly sizeInterface: number;
+  readonly sizePrompt: number;
+  readonly sizeCode: number;
 }
 
 /**
- * Apply the preferences to the root element. Unset preferences remove the
+ * Apply the preferences to the root element. Unset families remove the
  * override so the stylesheet defaults (and theme changes) stay in charge.
  *
- * Alongside each family we set a `font-size-adjust` value: typefaces differ in
- * x-height at the same pixel size (DM Sans 0.51 vs JetBrains Mono 0.55), so a
- * chosen font otherwise reads noticeably larger or smaller than the default.
- * The value is measured at runtime against the default stack and capped by
- * `clampSizeAdjust`; an unset preference removes it entirely, so the platform
- * default always renders exactly as it did before.
+ * Sizes are always written: the interface size drives the root font size (and
+ * with it every rem-based dimension), while the prompt and code sizes stay in
+ * absolute pixels so they do not scale twice.
  */
 export function applyAppearanceFontVariables(
   root: HTMLElement,
   preferences: AppearanceFontPreferences,
 ): void {
-  const assignments: ReadonlyArray<
-    readonly [family: string, adjust: string, custom: string, defaultStack: string]
-  > = [
-    ["--font-sans", "--font-sans-adjust", preferences.sans, DEFAULT_SANS_FONT_STACK],
-    ["--font-mono", "--font-mono-adjust", preferences.code, DEFAULT_CODE_FONT_STACK],
+  const families: ReadonlyArray = [
+    ["--font-sans", preferences.sans, DEFAULT_SANS_FONT_STACK],
+    ["--font-mono", preferences.code, DEFAULT_CODE_FONT_STACK],
     // The composer falls back to whatever the sans preference resolves to.
-    ["--font-composer", "--font-composer-adjust", preferences.composer, "var(--font-sans)"],
+    ["--font-composer", preferences.composer, "var(--font-sans)"],
   ];
-  for (const [variable, adjustVariable, custom, defaultStack] of assignments) {
-    const families = cssFontFamilies(custom);
-    if (families === null) {
+  for (const [variable, custom, fallback] of families) {
+    const list = cssFontFamilies(custom);
+    if (list === null) {
       root.style.removeProperty(variable);
-      root.style.removeProperty(adjustVariable);
-      continue;
+    } else {
+      root.style.setProperty(variable, `${list}, ${fallback}`);
     }
-    root.style.setProperty(variable, `${families}, ${defaultStack}`);
-    // Normalize against the stock stack: the composer's nominal default is the
-    // sans variable, but that may itself be overridden (and already adjusted).
-    const reference = defaultStack === "var(--font-sans)" ? DEFAULT_SANS_FONT_STACK : defaultStack;
-    root.style.setProperty(
-      adjustVariable,
-      fontSizeAdjustValue(`${families}, ${reference}`, reference),
-    );
   }
-}
 
-const X_HEIGHT_PROBE_SIZE = 100;
-const xHeightCache = new Map();
-let fontLoadInvalidationHooked = false;
-
-/**
- * Measurements taken before the bundled webfonts finish loading describe the
- * fallback face, not the real default (DM Sans 0.51 vs a generic 0.55), so
- * drop the cache whenever a face finishes loading and let callers re-measure.
- */
-function hookFontLoadInvalidation(): void {
-  if (fontLoadInvalidationHooked) return;
-  if (typeof document === "undefined" || document.fonts === undefined) return;
-  fontLoadInvalidationHooked = true;
-  document.fonts.addEventListener("loadingdone", () => {
-    xHeightCache.clear();
-  });
+  root.style.fontSize = `${clampInterfaceFontSize(preferences.sizeInterface)}px`;
+  root.style.setProperty("--font-size-prompt", `${clampPromptFontSize(preferences.sizePrompt)}px`);
+  const code = clampCodeFontSize(preferences.sizeCode);
+  root.style.setProperty("--font-size-code", `${code}px`);
+  // The @pierre/diffs surfaces read their own hook for code text.
+  root.style.setProperty("--diffs-font-size", `${code}px`);
 }
 
-/**
- * The x-height of a font list as a fraction of its em size, or null when it
- * cannot be measured (no canvas, or a font that reports no glyph bounds).
- */
-export function fontXHeightRatio(fontList: string): number | null {
-  const families = cssFontFamilies(fontList);
-  if (families === null) return null;
-  hookFontLoadInvalidation();
-  const cached = xHeightCache.get(families);
-  if (cached !== undefined) return cached;
-  try {
-    if (fontProbeContext === undefined) {
-      fontProbeContext = document.createElement("canvas").getContext("2d");
-    }
-    if (fontProbeContext === null) return null;
-    fontProbeContext.font = `${X_HEIGHT_PROBE_SIZE}px ${families}`;
-    const ascent = fontProbeContext.measureText("x").actualBoundingBoxAscent;
-    const ratio =
-      Number.isFinite(ascent) && ascent > 0
-        ? Math.round((ascent / X_HEIGHT_PROBE_SIZE) * 1000) / 1000
-        : null;
-    xHeightCache.set(families, ratio);
-    return ratio;
-  } catch {
-    return null;
-  }
+function clampFontSize(value: number, minimum: number, maximum: number, fallback: number): number {
+  if (!Number.isFinite(value)) return fallback;
+  return Math.min(maximum, Math.max(minimum, Math.round(value)));
 }
 
-/**
- * Matching x-heights exactly is too aggressive for faces with unusual
- * proportions: Courier New's x-height is ~0.42 em against DM Sans' ~0.51, so
- * an exact match scales it 21% up and its capitals and ascenders tower. Cap
- * the correction so a font still reads at a comparable size without the rest
- * of its design being blown out of proportion.
- */
-const MIN_SIZE_ADJUST_SCALE = 0.94;
-const MAX_SIZE_ADJUST_SCALE = 1.06;
-
-export function clampSizeAdjust(targetRatio: number, actualRatio: number): number {
-  const lower = actualRatio * MIN_SIZE_ADJUST_SCALE;
-  const upper = actualRatio * MAX_SIZE_ADJUST_SCALE;
-  return Math.round(Math.min(Math.max(targetRatio, lower), upper) * 1000) / 1000;
+export function clampInterfaceFontSize(value: number): number {
+  return clampFontSize(
+    value,
+    MIN_INTERFACE_FONT_SIZE,
+    MAX_INTERFACE_FONT_SIZE,
+    DEFAULT_INTERFACE_FONT_SIZE,
+  );
 }
 
-/**
- * The `font-size-adjust` value that brings `fontList` closest to the apparent
- * size of `defaultStack`, or "none" when either cannot be measured. Identical
- * stacks resolve to the default's own ratio, leaving rendering untouched.
- */
-export function fontSizeAdjustValue(fontList: string, defaultStack: string): string {
-  const target = fontXHeightRatio(defaultStack);
-  const actual = fontXHeightRatio(fontList);
-  if (target === null || actual === null || actual <= 0) return "none";
-  return String(clampSizeAdjust(target, actual));
+export function clampPromptFontSize(value: number): number {
+  return clampFontSize(value, MIN_PROMPT_FONT_SIZE, MAX_PROMPT_FONT_SIZE, DEFAULT_PROMPT_FONT_SIZE);
 }
 
-/** Re-run `handler` whenever a font finishes loading and metrics may change. */
-export function subscribeToFontLoads(handler: () => void): () => void {
-  if (typeof document === "undefined" || document.fonts === undefined) return () => {};
-  document.fonts.addEventListener("loadingdone", handler);
-  return () => document.fonts.removeEventListener("loadingdone", handler);
+export function clampCodeFontSize(value: number): number {
+  return clampFontSize(value, MIN_CODE_FONT_SIZE, MAX_CODE_FONT_SIZE, DEFAULT_CODE_FONT_SIZE);
 }
 
 export type FontCategory = "Sans serif" | "Monospace";
diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx
index e80a515489f3..f64bdedaa59c 100644
--- a/apps/web/src/components/ComposerPromptEditor.tsx
+++ b/apps/web/src/components/ComposerPromptEditor.tsx
@@ -1752,7 +1752,9 @@ function ComposerPromptEditorInner({
           contentEditable={
              Appearance
+                // can drive it; keep everything else here.
+                "block max-h-50 min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none",
                 className,
               )}
               data-testid="composer-editor"
@@ -1763,7 +1765,7 @@ function ComposerPromptEditorInner({
           }
           placeholder={
             terminalContexts.length > 0 ? null : (
-              
+
{placeholder}
) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index fcf11279ee32..c0307ceff0d3 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -132,6 +132,12 @@ function normalizeComputedColor(value: string | null | undefined, fallback: stri return value ?? fallback; } +/** The surface treats an omitted family or size as "use the built-in default". */ +function terminalFontOptions(family: string, size: number): { family?: string; size: number } { + const trimmed = family.trim(); + return trimmed.length > 0 ? { family: trimmed, size } : { size }; +} + function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { const isDark = document.documentElement.classList.contains("dark"); const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; @@ -307,7 +313,8 @@ export function TerminalViewport({ }); const readTerminalLabel = useEffectEvent(() => terminalLabel); const terminalFontFamily = useClientSettings((settings) => settings.fontFamilyTerminal); - const terminalFontFamilyRef = useRef(terminalFontFamily); + const terminalFontSize = useClientSettings((settings) => settings.fontSizeTerminal); + const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize }); const terminalSession = useAttachedTerminalSession({ environmentId, terminal: { @@ -371,11 +378,11 @@ export function TerminalViewport({ }, [keybindings]); useEffect(() => { - if (terminalFontFamilyRef.current === terminalFontFamily) return; - terminalFontFamilyRef.current = terminalFontFamily; - const family = terminalFontFamily.trim(); - void terminalRef.current?.setFont(family.length > 0 ? { family } : {}); - }, [terminalFontFamily]); + const current = terminalFontRef.current; + if (current.family === terminalFontFamily && current.size === terminalFontSize) return; + terminalFontRef.current = { family: terminalFontFamily, size: terminalFontSize }; + void terminalRef.current?.setFont(terminalFontOptions(terminalFontFamily, terminalFontSize)); + }, [terminalFontFamily, terminalFontSize]); useEffect(() => { const mount = containerRef.current; @@ -388,10 +395,10 @@ export function TerminalViewport({ let setupCleanups: Array<() => void> = []; const setup = async (): Promise<(() => void) | null> => { - const setupFontFamily = terminalFontFamilyRef.current; + const setupFont = terminalFontRef.current; const terminalOptions: GhosttyTerminalSurfaceOptions = { theme: terminalThemeFromApp(mount), - ...(setupFontFamily.length > 0 ? { font: { family: setupFontFamily } } : {}), + font: terminalFontOptions(setupFont.family, setupFont.size), onData: (data) => handleData(data), onResize: (cols, rows) => void resizeTerminal(cols, rows), onSelectionChange: () => handleSelectionChange(), @@ -412,9 +419,9 @@ export function TerminalViewport({ // Client settings hydrate asynchronously; a font preference that landed // while the surface was loading found terminalRef null, so its setFont // was dropped. Re-apply whatever is current once the terminal exists. - if (terminalFontFamilyRef.current !== setupFontFamily) { - const family = terminalFontFamilyRef.current.trim(); - void terminal.setFont(family.length > 0 ? { family } : {}); + const currentFont = terminalFontRef.current; + if (currentFont.family !== setupFont.family || currentFont.size !== setupFont.size) { + void terminal.setFont(terminalFontOptions(currentFont.family, currentFont.size)); } const latestSession = latestSessionRef.current; previousSessionRef.current = latestSession; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 69994d0cc214..18c3cce18ddc 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -35,8 +35,16 @@ import { DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE, DEFAULT_UNIFIED_SETTINGS, type EnvironmentIdentificationMode, + MAX_CODE_FONT_SIZE, MAX_GLASS_OPACITY, + MAX_INTERFACE_FONT_SIZE, + MAX_PROMPT_FONT_SIZE, + MAX_TERMINAL_FONT_SIZE, + MIN_CODE_FONT_SIZE, MIN_GLASS_OPACITY, + MIN_INTERFACE_FONT_SIZE, + MIN_PROMPT_FONT_SIZE, + MIN_TERMINAL_FONT_SIZE, } from "@t3tools/contracts/settings"; import { getBackgroundActivityBaseProfile, @@ -107,9 +115,7 @@ import { appearanceFontStack, availableFontOptions, fontOptionCategories, - fontSizeAdjustValue, isFontFamilyAvailable, - subscribeToFontLoads, type FontOption, } from "../../appearanceFonts"; import { @@ -665,6 +671,10 @@ export function useSettingsRestore(onRestored?: () => void) { settings.fontFamilyComposer, settings.fontFamilySans, settings.fontFamilyTerminal, + settings.fontSizeCode, + settings.fontSizeInterface, + settings.fontSizePrompt, + settings.fontSizeTerminal, settings.glassOpacity, settings.enableAssistantStreaming, settings.enableProviderUpdateChecks, @@ -1007,37 +1017,14 @@ export function AppearanceSettingsPanel() { : sansStack; const codeStack = appearanceFontStack(settings.fontFamilyCode, DEFAULT_CODE_FONT_STACK); const terminalStack = appearanceFontStack(settings.fontFamilyTerminal, DEFAULT_CODE_FONT_STACK); - // Each preview normalizes its own stack, so it shows exactly the size the - // app will render. The epoch re-resolves them once webfonts have loaded. - const fontMetricsEpoch = useFontMetricsEpoch(); - const sansAdjust = useMemo( - () => fontSizeAdjustValue(sansStack, DEFAULT_SANS_FONT_STACK), - // eslint-disable-next-line react-hooks/exhaustive-deps -- epoch is the invalidation signal - [sansStack, fontMetricsEpoch], - ); - const composerAdjust = useMemo( - () => fontSizeAdjustValue(composerStack, DEFAULT_SANS_FONT_STACK), - // eslint-disable-next-line react-hooks/exhaustive-deps -- epoch is the invalidation signal - [composerStack, fontMetricsEpoch], - ); - const codeAdjust = useMemo( - () => fontSizeAdjustValue(codeStack, DEFAULT_CODE_FONT_STACK), - // eslint-disable-next-line react-hooks/exhaustive-deps -- epoch is the invalidation signal - [codeStack, fontMetricsEpoch], - ); - const terminalAdjust = useMemo( - () => fontSizeAdjustValue(terminalStack, DEFAULT_CODE_FONT_STACK), - // eslint-disable-next-line react-hooks/exhaustive-deps -- epoch is the invalidation signal - [terminalStack, fontMetricsEpoch], - ); const environmentStageLabel = useEnvironmentStageLabel(); const showEnvironmentIdentification = resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null; const glassOpacityRatio = (settings.glassOpacity - MIN_GLASS_OPACITY) / (MAX_GLASS_OPACITY - MIN_GLASS_OPACITY); const glassOpacitySliderStyle = { - "--glass-slider-progress": `${glassOpacityRatio * 100}%`, - "--glass-slider-fill-offset": `${0.5 - glassOpacityRatio}rem`, + "--settings-slider-progress": `${glassOpacityRatio * 100}%`, + "--settings-slider-fill-offset": `${0.5 - glassOpacityRatio}rem`, } as CSSProperties; return ( @@ -1099,7 +1086,7 @@ export function AppearanceSettingsPanel() { updateSettings({ fontFamilySans })} + size={{ + label: "Interface font size", + min: MIN_INTERFACE_FONT_SIZE, + max: MAX_INTERFACE_FONT_SIZE, + value: settings.fontSizeInterface, + onChange: (fontSizeInterface) => updateSettings({ fontSizeInterface }), + }} preview={ - -

+ +

The quick brown fox jumps over the lazy dog.

-

+

Messages, labels, and headings across the app.

@@ -1213,13 +1207,20 @@ export function AppearanceSettingsPanel() { options={fontOptions} value={settings.fontFamilyComposer} onValueChange={(fontFamilyComposer) => updateSettings({ fontFamilyComposer })} + size={{ + label: "Prompt font size", + min: MIN_PROMPT_FONT_SIZE, + max: MAX_PROMPT_FONT_SIZE, + value: settings.fontSizePrompt, + onChange: (fontSizePrompt) => updateSettings({ fontSizePrompt }), + }} preview={ - +
-

+

Fix the flaky test in surface.test.ts and explain the race.

-

+

Ask for follow-up changes or attach images

@@ -1232,12 +1233,16 @@ export function AppearanceSettingsPanel() { options={fontOptions} value={settings.fontFamilyCode} onValueChange={(fontFamilyCode) => updateSettings({ fontFamilyCode })} + size={{ + label: "Code font size", + min: MIN_CODE_FONT_SIZE, + max: MAX_CODE_FONT_SIZE, + value: settings.fontSizeCode, + onChange: (fontSizeCode) => updateSettings({ fontSizeCode }), + }} preview={ - -
+            
+              
                 
                   1
                   {"  "}
@@ -1265,9 +1270,16 @@ export function AppearanceSettingsPanel() {
           options={fontOptions}
           value={settings.fontFamilyTerminal}
           onValueChange={(fontFamilyTerminal) => updateSettings({ fontFamilyTerminal })}
+          size={{
+            label: "Terminal font size",
+            min: MIN_TERMINAL_FONT_SIZE,
+            max: MAX_TERMINAL_FONT_SIZE,
+            value: settings.fontSizeTerminal,
+            onChange: (fontSizeTerminal) => updateSettings({ fontSizeTerminal }),
+          }}
           preview={
-            
-              
+            
+              
                 
                   ${" "}
                   npm run dev
@@ -1289,39 +1301,63 @@ export function AppearanceSettingsPanel() {
 const CUSTOM_FONT_VALUE = "__custom__";
 const DEFAULT_FONT_VALUE = "__default__";
 
-/**
- * Every font renders at the default stack's x-height, so switching family in a
- * preview (or scanning the dropdown) shows the typeface changing without the
- * apparent text size jumping with it. Mirrors the runtime `font-size-adjust`.
- */
-function optionSizeAdjust(family: string): string {
-  return fontSizeAdjustValue(family, DEFAULT_SANS_FONT_STACK);
-}
-
-/**
- * Re-render once webfonts load: metrics measured before then describe the
- * fallback face, so the adjust values would be stale.
- */
-function useFontMetricsEpoch(): number {
-  const [epoch, setEpoch] = useState(0);
-  useEffect(() => subscribeToFontLoads(() => setEpoch((value) => value + 1)), []);
-  return epoch;
+/** Mirrors the mobile Appearance sliders: a labelled value and a filled track. */
+function FontSizeSlider({
+  label,
+  max,
+  min,
+  onChange,
+  value,
+}: {
+  label: string;
+  max: number;
+  min: number;
+  onChange: (value: number) => void;
+  value: number;
+}) {
+  const ratio = (value - min) / (max - min);
+  const style = {
+    "--settings-slider-progress": `${ratio * 100}%`,
+    "--settings-slider-fill-offset": `${0.5 - ratio}rem`,
+  } as CSSProperties;
+  return (
+    
+ { + const next = Number(event.currentTarget.value); + if (Number.isInteger(next) && next >= min && next <= max) onChange(next); + }} + step={1} + style={style} + type="range" + value={value} + /> + + {value} px + +
+ ); } +/** Renders in the family and size the surface will actually use. */ function FontPreviewCard({ children, stack, - adjust, + size, }: { children: ReactNode; stack: string; - adjust: string; + size: number; }) { return (
{children}
@@ -1335,6 +1371,7 @@ function FontFamilySettingsRow({ preview, value, onValueChange, + size, }: { title: string; description: string; @@ -1342,6 +1379,7 @@ function FontFamilySettingsRow({ preview: ReactNode; value: string; onValueChange: (value: string) => void; + size: { label: string; min: number; max: number; value: number; onChange: (v: number) => void }; }) { const trimmed = value.trim(); const matchesOption = options.some((option) => option.family === trimmed); @@ -1413,138 +1451,140 @@ function FontFamilySettingsRow({ ) : null } control={ - // The custom input replaces the dropdown rather than stacking under it, - // so entering custom mode does not change the row height. -
- {showCustomInput ? ( - <> - { - const next = event.currentTarget.value; - setCustomDraft(next); - setDraftSettled(false); - if (commitTimerRef.current !== null) { - window.clearTimeout(commitTimerRef.current); +
+ {/* The custom input replaces the dropdown rather than stacking under + it, so entering custom mode does not change the row height. */} +
+ {showCustomInput ? ( + <> + { + const next = event.currentTarget.value; + setCustomDraft(next); + setDraftSettled(false); + if (commitTimerRef.current !== null) { + window.clearTimeout(commitTimerRef.current); + } + commitTimerRef.current = window.setTimeout(() => { + commitTimerRef.current = null; + commitDraft(next); + }, 400); + }} + onKeyDown={(event) => { + if (event.key === "Enter") flushDraft(); + if (event.key === "Escape") { + // Discard uncommitted typing without leaving the settings + // page (Escape closes it), and drop back to the list only + // when there is no committed family to return to. + event.preventDefault(); + event.stopPropagation(); + if (commitTimerRef.current !== null) { + window.clearTimeout(commitTimerRef.current); + commitTimerRef.current = null; + } + setCustomDraft(value); + setDraftSettled(true); + if (trimmed.length === 0) setCustomMode(false); + } + }} + placeholder="Font family name" + spellCheck={false} + value={customDraft} + /> + + { + setCustomMode(false); + onValueChange(""); + }} + size="icon-sm" + variant="ghost" + > + + + } + /> + Choose from the list + + + ) : ( + { - if (typeof next !== "string") return; - if (next === DEFAULT_FONT_VALUE) { setCustomMode(false); - onValueChange(""); - return; - } - if (next === CUSTOM_FONT_VALUE) { - // Start from an empty field rather than the outgoing family; - // the applied font holds until a valid name is entered. - if (commitTimerRef.current !== null) { - window.clearTimeout(commitTimerRef.current); - commitTimerRef.current = null; - } - setCustomDraft(""); - setDraftSettled(true); - setCustomMode(true); - return; - } - setCustomMode(false); - onValueChange(next); - }} - > - - - {selected === DEFAULT_FONT_VALUE - ? "Default" - : (options.find((option) => option.family === selected)?.label ?? selected)} - - - - - Default - - {categories.map(([category, categoryOptions]) => ( - - {/* A lone section header is noise; label only mixed lists. */} - {categories.length > 1 ? ( - - {category} - - ) : null} - {categoryOptions.map((option) => ( - - - {option.label} - - - ))} - - ))} - - Custom… - - - - )} + onValueChange(next); + }} + > + + + {selected === DEFAULT_FONT_VALUE + ? "Default" + : (options.find((option) => option.family === selected)?.label ?? selected)} + + + + + Default + + {categories.map(([category, categoryOptions]) => ( + + {/* A lone section header is noise; label only mixed lists. */} + {categories.length > 1 ? ( + + {category} + + ) : null} + {categoryOptions.map((option) => ( + + {option.label} + + ))} + + ))} + + Custom… + + + + )} +
+
} > diff --git a/apps/web/src/index.css b/apps/web/src/index.css index c0206faef7b3..3452d284f740 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -644,11 +644,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil background: color-mix(in srgb, var(--background) 64%, transparent); } - .glass-opacity-slider { - --glass-slider-progress: 0%; - --glass-slider-fill-offset: 0.5rem; - --glass-slider-fill-position: calc( - var(--glass-slider-progress) + var(--glass-slider-fill-offset) + .settings-slider { + --settings-slider-progress: 0%; + --settings-slider-fill-offset: 0.5rem; + --settings-slider-fill-position: calc( + var(--settings-slider-progress) + var(--settings-slider-fill-offset) ); appearance: none; height: 1.5rem; @@ -656,32 +656,32 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil cursor: pointer; } - .glass-opacity-slider::-webkit-slider-runnable-track { + .settings-slider::-webkit-slider-runnable-track { height: 0.375rem; border-radius: 9999px; background: linear-gradient( to right, - var(--primary) 0 var(--glass-slider-fill-position), - color-mix(in srgb, var(--muted-foreground) 22%, transparent) var(--glass-slider-fill-position) - 100% + var(--primary) 0 var(--settings-slider-fill-position), + color-mix(in srgb, var(--muted-foreground) 22%, transparent) + var(--settings-slider-fill-position) 100% ); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border) 55%, transparent); } - .glass-opacity-slider::-moz-range-track { + .settings-slider::-moz-range-track { height: 0.375rem; border-radius: 9999px; background: color-mix(in srgb, var(--muted-foreground) 22%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border) 55%, transparent); } - .glass-opacity-slider::-moz-range-progress { + .settings-slider::-moz-range-progress { height: 0.375rem; border-radius: 9999px; background: var(--primary); } - .glass-opacity-slider::-webkit-slider-thumb { + .settings-slider::-webkit-slider-thumb { appearance: none; width: 1rem; height: 1rem; @@ -695,7 +695,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil box-shadow 120ms ease; } - .glass-opacity-slider::-moz-range-thumb { + .settings-slider::-moz-range-thumb { width: 1rem; height: 1rem; border: 2px solid var(--primary); @@ -707,54 +707,54 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil box-shadow 120ms ease; } - .glass-opacity-slider:hover::-webkit-slider-thumb { + .settings-slider:hover::-webkit-slider-thumb { transform: scale(1.08); box-shadow: 0 1px 3px color-mix(in srgb, var(--foreground) 20%, transparent); } - .glass-opacity-slider:hover::-moz-range-thumb { + .settings-slider:hover::-moz-range-thumb { transform: scale(1.08); box-shadow: 0 1px 3px color-mix(in srgb, var(--foreground) 20%, transparent); } - .glass-opacity-slider:active::-webkit-slider-thumb { + .settings-slider:active::-webkit-slider-thumb { transform: scale(0.94); } - .glass-opacity-slider:active::-moz-range-thumb { + .settings-slider:active::-moz-range-thumb { transform: scale(0.94); } - .glass-opacity-slider:focus-visible { + .settings-slider:focus-visible { outline: none; } - .glass-opacity-slider:focus-visible::-webkit-slider-thumb { + .settings-slider:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 3px var(--background), 0 0 0 5px var(--ring); } - .glass-opacity-slider:focus-visible::-moz-range-thumb { + .settings-slider:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 3px var(--background), 0 0 0 5px var(--ring); } @media (forced-colors: active) { - .glass-opacity-slider { + .settings-slider { appearance: auto; accent-color: Highlight; } - .glass-opacity-slider::-webkit-slider-runnable-track, - .glass-opacity-slider::-webkit-slider-thumb { + .settings-slider::-webkit-slider-runnable-track, + .settings-slider::-webkit-slider-thumb { all: revert; } - .glass-opacity-slider::-moz-range-track, - .glass-opacity-slider::-moz-range-progress, - .glass-opacity-slider::-moz-range-thumb { + .settings-slider::-moz-range-track, + .settings-slider::-moz-range-progress, + .settings-slider::-moz-range-thumb { all: revert; } } @@ -963,11 +963,8 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil body { /* Reference the theme token (not a literal stack) so the Settings -> - Appearance runtime override of --font-sans reaches all interface text. - The adjust normalizes a chosen font's x-height to the default stack's, so - switching family does not also change apparent text size. */ + Appearance runtime override of --font-sans reaches all interface text. */ font-family: var(--font-sans); - font-size-adjust: var(--font-sans-adjust, none); margin: 0; padding: 0; } @@ -1019,7 +1016,6 @@ body { pre, code { font-family: var(--font-mono); - font-size-adjust: var(--font-mono-adjust, none); } /* @pierre/diffs surfaces (diffs, file previews, annotatable code, search @@ -1068,7 +1064,15 @@ code { the editor and its placeholder inherit together. */ .composer-editor-surface { font-family: var(--font-composer, var(--font-sans)); - font-size-adjust: var(--font-composer-adjust, var(--font-sans-adjust, none)); + font-size: var(--font-size-prompt, 0.875rem); +} + +/* Phone browsers zoom the page when a focused field is under 16px, so keep the + floor there regardless of the preference. */ +@media (max-width: 39.999rem) { + .composer-editor-surface { + font-size: max(var(--font-size-prompt, 1rem), 16px); + } } .t3-ghostty-canvas { diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 8aa6c3e7f864..b02445b0af9c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -27,7 +27,7 @@ import { toastManager, } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; -import { applyAppearanceFontVariables, subscribeToFontLoads } from "~/appearanceFonts"; +import { applyAppearanceFontVariables } from "~/appearanceFonts"; import { useClientSettings } from "../hooks/useSettings"; import { deriveLogicalProjectKeyFromSettings, @@ -158,19 +158,27 @@ function FontAppearanceSync() { const fontFamilySans = useClientSettings((settings) => settings.fontFamilySans); const fontFamilyCode = useClientSettings((settings) => settings.fontFamilyCode); const fontFamilyComposer = useClientSettings((settings) => settings.fontFamilyComposer); + const fontSizeInterface = useClientSettings((settings) => settings.fontSizeInterface); + const fontSizePrompt = useClientSettings((settings) => settings.fontSizePrompt); + const fontSizeCode = useClientSettings((settings) => settings.fontSizeCode); useEffect(() => { - const apply = () => - applyAppearanceFontVariables(document.documentElement, { - sans: fontFamilySans, - code: fontFamilyCode, - composer: fontFamilyComposer, - }); - apply(); - // The size adjust is measured from the default stack; a webfont that loads - // after the first pass changes that measurement, so re-apply once settled. - return subscribeToFontLoads(apply); - }, [fontFamilyCode, fontFamilyComposer, fontFamilySans]); + applyAppearanceFontVariables(document.documentElement, { + sans: fontFamilySans, + code: fontFamilyCode, + composer: fontFamilyComposer, + sizeInterface: fontSizeInterface, + sizePrompt: fontSizePrompt, + sizeCode: fontSizeCode, + }); + }, [ + fontFamilyCode, + fontFamilyComposer, + fontFamilySans, + fontSizeCode, + fontSizeInterface, + fontSizePrompt, + ]); return null; } diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9efd0b3deb9a..4199c9ca3bfc 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -58,6 +58,43 @@ export const GlassOpacity = Schema.Int.check( ); export type GlassOpacity = typeof GlassOpacity.Type; export const DEFAULT_GLASS_OPACITY: GlassOpacity = 80; +/** + * Font size preferences, in CSS pixels. The ranges are deliberately narrow: + * the interface size scales every rem-based dimension in the app, so the + * bounds keep layouts intact rather than offering unusable extremes. + */ +export const MIN_INTERFACE_FONT_SIZE = 12; +export const MAX_INTERFACE_FONT_SIZE = 20; +export const InterfaceFontSize = Schema.Int.check( + Schema.isBetween({ minimum: MIN_INTERFACE_FONT_SIZE, maximum: MAX_INTERFACE_FONT_SIZE }), +); +export type InterfaceFontSize = typeof InterfaceFontSize.Type; +export const DEFAULT_INTERFACE_FONT_SIZE: InterfaceFontSize = 16; + +export const MIN_PROMPT_FONT_SIZE = 12; +export const MAX_PROMPT_FONT_SIZE = 20; +export const PromptFontSize = Schema.Int.check( + Schema.isBetween({ minimum: MIN_PROMPT_FONT_SIZE, maximum: MAX_PROMPT_FONT_SIZE }), +); +export type PromptFontSize = typeof PromptFontSize.Type; +export const DEFAULT_PROMPT_FONT_SIZE: PromptFontSize = 14; + +export const MIN_CODE_FONT_SIZE = 10; +export const MAX_CODE_FONT_SIZE = 18; +export const CodeFontSize = Schema.Int.check( + Schema.isBetween({ minimum: MIN_CODE_FONT_SIZE, maximum: MAX_CODE_FONT_SIZE }), +); +export type CodeFontSize = typeof CodeFontSize.Type; +export const DEFAULT_CODE_FONT_SIZE: CodeFontSize = 13; + +export const MIN_TERMINAL_FONT_SIZE = 8; +export const MAX_TERMINAL_FONT_SIZE = 20; +export const TerminalFontSize = Schema.Int.check( + Schema.isBetween({ minimum: MIN_TERMINAL_FONT_SIZE, maximum: MAX_TERMINAL_FONT_SIZE }), +); +export type TerminalFontSize = typeof TerminalFontSize.Type; +export const DEFAULT_TERMINAL_FONT_SIZE: TerminalFontSize = 12; + export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", "none"]); export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type; export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork"; @@ -83,6 +120,18 @@ export const ClientSettingsSchema = Schema.Struct({ glassOpacity: GlassOpacity.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_GLASS_OPACITY)), ), + fontSizeInterface: InterfaceFontSize.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_INTERFACE_FONT_SIZE)), + ), + fontSizePrompt: PromptFontSize.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_PROMPT_FONT_SIZE)), + ), + fontSizeCode: CodeFontSize.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_CODE_FONT_SIZE)), + ), + fontSizeTerminal: TerminalFontSize.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_TERMINAL_FONT_SIZE)), + ), fontFamilyCode: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), fontFamilyComposer: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), fontFamilySans: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -691,6 +740,10 @@ export const ClientSettingsPatch = Schema.Struct({ diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), glassOpacity: Schema.optionalKey(GlassOpacity), + fontSizeInterface: Schema.optionalKey(InterfaceFontSize), + fontSizePrompt: Schema.optionalKey(PromptFontSize), + fontSizeCode: Schema.optionalKey(CodeFontSize), + fontSizeTerminal: Schema.optionalKey(TerminalFontSize), fontFamilyCode: Schema.optionalKey(FontFamilyPreference), fontFamilyComposer: Schema.optionalKey(FontFamilyPreference), fontFamilySans: Schema.optionalKey(FontFamilyPreference), From 162d5b4acb3efc3c026234b68fb5d8218c53a8ee Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:38:48 +0200 Subject: [PATCH 22/47] feat(web): collapse the composer context strip to icons when text outgrows it The branch, workspace, and environment controls sit in a fixed-width strip, so a larger interface font pushes their labels into each other. The strip is now a query container and the labels hide below a rem-based threshold - rem scales with the interface size, so the controls fall back to their icons exactly when the text would start to overlap, at any font size or window width. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/BranchToolbar.tsx | 2 +- apps/web/src/components/BranchToolbarBranchSelector.tsx | 4 +++- apps/web/src/components/BranchToolbarEnvModeSelector.tsx | 4 +++- apps/web/src/components/BranchToolbarEnvironmentSelector.tsx | 4 +++- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 3a83f5c9a0ff..32995863d589 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -304,7 +304,7 @@ export const BranchToolbar = memo(function BranchToolbar({ if (!hasActiveThread || !activeProject) return null; return ( -
+
{isMobile ? ( - {triggerLabel} + + {triggerLabel} + diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index d300139d3cf5..1e9cab9bcc80 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -92,7 +92,9 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe ) : ( )} - + + + diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index e4ed54758ff4..d36b6d0d67dc 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -49,7 +49,9 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir ) : ( )} - {activeEnvironment?.label ?? "Run on"} + + {activeEnvironment?.label ?? "Run on"} + ); } From ed70fa4c8f005438d5b6cbdd75c765e9a33d5f33 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:51:30 +0200 Subject: [PATCH 23/47] fix(web): keep proportional fonts out of the grid surfaces, collapse labels only on overflow The terminal and code views render on a fixed cell grid, so a proportional face like Helvetica Neue cannot line up - their pickers now offer monospace faces only, while the interface and prompt keep the full catalog. The composer strip's labels collapsed at a guessed container width, which hid them even when they fitted. It now measures how far each label is clipped and collapses only once they genuinely stop fitting, remembering the required width so hiding them cannot flip the decision back. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/BranchToolbar.tsx | 54 ++++++++++++++++++- .../BranchToolbarBranchSelector.tsx | 5 +- .../BranchToolbarEnvModeSelector.tsx | 2 +- .../BranchToolbarEnvironmentSelector.tsx | 2 +- .../components/settings/SettingsPanels.tsx | 13 +++-- 5 files changed, 66 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 32995863d589..58d1b3a0cea8 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -9,7 +9,7 @@ import { HistoryIcon, MonitorIcon, } from "lucide-react"; -import { memo, useCallback, useMemo } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; @@ -214,6 +214,50 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ ); }); +/** + * Collapse the strip's labels to icons only once they actually stop fitting. + * The natural width is remembered from the last expanded measurement, so the + * hidden labels cannot shrink the content and flip the decision straight back. + */ +function useLabelsOverflow(ref: RefObject): boolean { + const [overflows, setOverflows] = useState(false); + const naturalWidthRef = useRef(0); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + const measure = () => { + const available = element.clientWidth; + if (available === 0) return; + if (!overflows) { + // Labels truncate rather than overflow, so the strip's own scrollWidth + // never grows. Sum how much each label is clipped by instead: that + // deficit plus the current width is what the row really needs. + let deficit = 0; + for (const label of element.querySelectorAll("[data-composer-label]")) { + deficit += Math.max(0, label.scrollWidth - label.clientWidth); + } + naturalWidthRef.current = available + deficit; + } + if (naturalWidthRef.current === 0) return; + setOverflows(naturalWidthRef.current > available + 1); + }; + + measure(); + const observer = new ResizeObserver(measure); + observer.observe(element); + // A font that finishes loading, or a size preference change, moves labels. + document.fonts.addEventListener("loadingdone", measure); + return () => { + observer.disconnect(); + document.fonts.removeEventListener("loadingdone", measure); + }; + }, [overflows, ref]); + + return overflows; +} + export const BranchToolbar = memo(function BranchToolbar({ environmentId, threadId, @@ -300,11 +344,17 @@ export const BranchToolbar = memo(function BranchToolbar({ canPickEnvironment: showEnvironmentPicker, }); const isMobile = useIsMobile(); + const stripRef = useRef(null); + const labelsOverflow = useLabelsOverflow(stripRef); if (!hasActiveThread || !activeProject) return null; return ( -
+
{isMobile ? ( - + {triggerLabel} diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 1e9cab9bcc80..7c975fb7477b 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -92,7 +92,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe ) : ( )} - + diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index d36b6d0d67dc..f5b52b506741 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -49,7 +49,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir ) : ( )} - + {activeEnvironment?.label ?? "Run on"} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 18c3cce18ddc..4a689e6b4df0 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1004,10 +1004,13 @@ export function AppearanceSettingsPanel() { const updateSettings = useUpdatePrimarySettings(); // Every dropdown offers the full catalog, split into category sections, so // any surface can point at any face - the categories carry the guidance. - const fontOptions = useMemo( + // Text surfaces take any face; the terminal and code render on a fixed cell + // grid, where a proportional font cannot line up, so they stay monospace. + const textFontOptions = useMemo( () => [...availableFontOptions(SANS_FONT_OPTIONS), ...availableFontOptions(MONO_FONT_OPTIONS)], [], ); + const monoFontOptions = useMemo(() => availableFontOptions(MONO_FONT_OPTIONS), []); const sansStack = appearanceFontStack(settings.fontFamilySans, DEFAULT_SANS_FONT_STACK); // The composer falls back to the resolved interface stack (not the bare // default) so the preview matches the runtime var(--font-composer) chain. @@ -1180,7 +1183,7 @@ export function AppearanceSettingsPanel() { updateSettings({ fontFamilySans })} size={{ @@ -1204,7 +1207,7 @@ export function AppearanceSettingsPanel() { updateSettings({ fontFamilyComposer })} size={{ @@ -1230,7 +1233,7 @@ export function AppearanceSettingsPanel() { updateSettings({ fontFamilyCode })} size={{ @@ -1267,7 +1270,7 @@ export function AppearanceSettingsPanel() { updateSettings({ fontFamilyTerminal })} size={{ From 2977bae3ba52c83f62e76052d6f8359cd0475da9 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:58:50 +0200 Subject: [PATCH 24/47] fix(web): refuse proportional faces in the terminal grid A proportional family draws each glyph on its own advance while the cursor and selection sit on the grid's single cell width, so the text drifts left of its cells and the cursor strands to the right - the ragged gaps and misplaced cursor seen with Helvetica Neue. The renderer now measures the requested family and falls back to the default when it is not monospace, so a value stored before the picker was restricted cannot leave the terminal in that state. Unmeasurable environments and absent faces are treated as monospace, leaving the normal fallback chain in charge. Co-Authored-By: Claude Fable 5 --- apps/web/src/appearanceFonts.ts | 29 +++++++++++++++++++ apps/web/src/terminal/ghostty/surface.test.ts | 8 +++++ apps/web/src/terminal/ghostty/surface.ts | 5 ++++ 3 files changed, 42 insertions(+) diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index f2d12c5799e1..86939a1316c2 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -216,6 +216,35 @@ export function isFontFamilyAvailable(family: string): boolean { } } +/** + * Whether a family renders every character on the same advance. Cell-grid + * surfaces (the terminal) require this: a proportional face draws its text + * narrower than the lattice the cursor and selection are placed on, which + * reads as ragged gaps and a cursor stranded to the right of the text. + * + * Unmeasurable environments answer true, so a missing canvas never blocks a + * legitimate font. + */ +export function isMonospaceFamily(family: string): boolean { + const families = cssFontFamilies(family); + if (families === null) return true; + try { + if (fontProbeContext === undefined) { + fontProbeContext = document.createElement("canvas").getContext("2d"); + } + if (fontProbeContext === null) return true; + // Fall back to a generic mono so an absent face measures as monospace and + // is left for the normal fallback chain to resolve. + fontProbeContext.font = `32px ${families}, monospace`; + const narrow = fontProbeContext.measureText("i").width; + const wide = fontProbeContext.measureText("M").width; + if (!Number.isFinite(narrow) || !Number.isFinite(wide) || wide === 0) return true; + return Math.abs(wide - narrow) < 0.5; + } catch { + return true; + } +} + /** Webfonts the app bundles; offered even before document.fonts has loaded them. */ const BUNDLED_FAMILIES = new Set(["DM Sans Variable", "JetBrains Mono"]); diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index dbebf98f773f..12889c5976ea 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -232,6 +232,14 @@ describe("terminal font resolution", () => { expect(custom.endsWith("monospace")).toBe(true); }); + it("ignores proportional families the cell grid cannot lay out", () => { + // jsdom has no canvas metrics, so the probe answers "monospace" and the + // family is kept; the guard is exercised in the browser instead. Assert the + // shape stays intact so a rejected face still yields a usable stack. + const stack = terminalFontFamily("Helvetica Neue"); + expect(stack.endsWith("monospace")).toBe(true); + }); + it("quotes families the canvas font shorthand would otherwise reject", () => { expect(terminalFontFamily("3270 Nerd Font").startsWith('"3270 Nerd Font", ')).toBe(true); expect(terminalFontFamily("M+ 1m").startsWith('"M+ 1m", ')).toBe(true); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 62fbf60be0e3..941f2f31babd 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -13,6 +13,7 @@ import { type GhosttyCellMetrics, } from "./renderer"; import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url"; +import { isMonospaceFamily } from "../../appearanceFonts"; export const DEFAULT_TERMINAL_FONT_SIZE = 12; const MIN_TERMINAL_FONT_SIZE = 6; @@ -78,6 +79,10 @@ export function terminalFontFamily(family?: string): string { // the whole canvas font string invalid and the assignment silently no-ops. const custom = family === undefined ? "" : quoteTerminalFontFamilies(family); if (custom.length === 0) return DEFAULT_TERMINAL_FONT_FAMILY; + // The grid places the cursor and selection on one cell advance, so a + // proportional face would draw its text narrower than its own cells. Refuse + // it here rather than render a ragged grid with a stranded cursor. + if (!isMonospaceFamily(custom)) return DEFAULT_TERMINAL_FONT_FAMILY; // A custom face keeps the glyph fallbacks so prompt symbols stay covered. return `${custom}, ${TERMINAL_GLYPH_FALLBACKS}`; } From c2999436d3078f665df3d9665f10d83e773f073a Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:38:48 +0200 Subject: [PATCH 25/47] feat(web): slide the terminal font size with the canvas width The size preference is a ceiling, not an absolute: narrow panes (splits, the side panel) now shrink the rendered size until a classic 80-column grid fits, down to a legibility floor, and widening slides it back up to the preference. The measurement lives in fit(), so pane drags, splits, and window resizes all pass through it. Co-Authored-By: Claude Fable 5 --- apps/web/src/terminal/ghostty/surface.test.ts | 19 +++++++ apps/web/src/terminal/ghostty/surface.ts | 55 +++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 12889c5976ea..5cb6574338fa 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -18,6 +18,7 @@ import { terminalLinkAtPosition, terminalContentOriginY, terminalFontFamily, + fittedTerminalFontSize, terminalFontSize, terminalWheelArrowData, terminalWheelDeltaRows, @@ -249,6 +250,24 @@ describe("terminal font resolution", () => { expect(terminalFontFamily(" , ")).toBe(DEFAULT_TERMINAL_FONT_FAMILY); }); + it("slides the rendered size down until a full-width grid fits the canvas", () => { + // SF Mono-like advance: 0.6em per cell. + const cellWidthAt = (size: number) => size * 0.6; + // A wide drawer keeps the preference untouched. + expect(fittedTerminalFontSize(cellWidthAt, 20, 1140)).toBe(20); + // A split pane at the same preference shrinks until 80 columns fit. + const fitted = fittedTerminalFontSize(cellWidthAt, 20, 570); + expect(fitted).toBeLessThan(20); + expect(Math.floor((570 - 8) / cellWidthAt(fitted))).toBeGreaterThanOrEqual(80); + // A tiny pane stops at the legibility floor instead of vanishing. + expect(fittedTerminalFontSize(cellWidthAt, 20, 220)).toBe(8); + // A preference below the floor is honored as-is. + expect(fittedTerminalFontSize(cellWidthAt, 6, 220)).toBe(6); + // Unmeasured layouts leave the preference alone. + expect(fittedTerminalFontSize(cellWidthAt, 14, 0)).toBe(14); + expect(fittedTerminalFontSize(() => 0, 14, 600)).toBe(14); + }); + it("clamps requested font sizes to the supported range", () => { expect(terminalFontSize()).toBe(DEFAULT_TERMINAL_FONT_SIZE); expect(terminalFontSize(Number.NaN)).toBe(DEFAULT_TERMINAL_FONT_SIZE); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 941f2f31babd..fd98a5287eb8 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -87,6 +87,42 @@ export function terminalFontFamily(family?: string): string { return `${custom}, ${TERMINAL_GLYPH_FALLBACKS}`; } +/** + * Grids narrower than a classic 80-column terminal wrap command output hard, + * so the rendered font size follows the canvas width: the preference is the + * ceiling, and the size slides down (to a legibility floor) until a full-width + * grid fits. A widening pane slides it back up toward the preference. + */ +const MIN_TERMINAL_FIT_COLUMNS = 80; +const MIN_TERMINAL_FIT_FONT_SIZE = 8; + +export function fittedTerminalFontSize( + cellWidthAt: (size: number) => number, + requested: number, + mountWidth: number, +): number { + const available = mountWidth - CONTENT_PADDING * 2; + if (available <= 0) return requested; + const floor = Math.min(requested, MIN_TERMINAL_FIT_FONT_SIZE); + const fits = (cellWidth: number) => + cellWidth > 0 && Math.floor(available / cellWidth) >= MIN_TERMINAL_FIT_COLUMNS; + let cellWidth = cellWidthAt(requested); + if (cellWidth <= 0 || fits(cellWidth)) return requested; + // The advance scales linearly with size for monospace faces: jump close to + // the fitting size, then settle the remaining rounding one step at a time. + const targetCellWidth = available / MIN_TERMINAL_FIT_COLUMNS; + let size = Math.max( + floor, + Math.min(requested, Math.floor((requested * targetCellWidth) / cellWidth)), + ); + while (size > floor) { + cellWidth = cellWidthAt(size); + if (cellWidth <= 0 || fits(cellWidth)) break; + size -= 1; + } + return size; +} + export function terminalFontSize(size?: number): number { if (size === undefined || !Number.isFinite(size)) return DEFAULT_TERMINAL_FONT_SIZE; return Math.max(MIN_TERMINAL_FONT_SIZE, Math.min(MAX_TERMINAL_FONT_SIZE, Math.round(size))); @@ -355,6 +391,7 @@ export class GhosttyTerminalSurface { private metrics: GhosttyCellMetrics; private fontFamily: string; private fontSize: number; + private requestedFontSize: number; private fontEpoch = 0; private readonly resizeObserver: ResizeObserver; private readonly scrollbarThumb: HTMLDivElement; @@ -427,6 +464,7 @@ export class GhosttyTerminalSurface { this.theme = options.theme; this.fontFamily = terminalFontFamily(options.font?.family); this.fontSize = terminalFontSize(options.font?.size); + this.requestedFontSize = this.fontSize; this.resizeObserver = new ResizeObserver(() => this.fit()); this.installEvents(); this.watchDevicePixelRatio(); @@ -542,6 +580,7 @@ export class GhosttyTerminalSurface { } if (this.disposed || epoch !== this.fontEpoch) return; this.fontFamily = fontFamily; + this.requestedFontSize = fontSize; this.fontSize = fontSize; this.applyFontMetrics(); } @@ -578,6 +617,22 @@ export class GhosttyTerminalSurface { const width = this.mount.clientWidth; const height = this.mount.clientHeight; if (width <= 0 || height <= 0) return false; + const fitted = fittedTerminalFontSize( + (size) => measureGhosttyCell(this.context, size, this.fontFamily).width, + this.requestedFontSize, + width, + ); + if (fitted !== this.fontSize) { + this.fontSize = fitted; + this.metrics = measureGhosttyCell(this.context, this.fontSize, this.fontFamily); + // The grid-change branch below resizes the core, but only when the + // column count moved; the cell geometry always did, so sync it here. + this.core.resize(this.cols, this.rows, this.metrics.width, this.metrics.height); + this.inputLeft = -1; + this.inputTop = -1; + this.forceFullRender = true; + this.scrollbarDirty = true; + } const ratio = window.devicePixelRatio || 1; const pixelWidth = Math.max(1, Math.round(width * ratio)); const pixelHeight = Math.max(1, Math.round(height * ratio)); From 0fcaa3bc7e169742ddfa9ca140ea12ae1c227c54 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:02:59 +0200 Subject: [PATCH 26/47] fix(web): truncate the context strip before collapsing, measure content not boxes The workspace trigger refused to shrink, so a large interface font made it overflow and paint over the branch name. Every control now truncates, which makes overlap impossible; icons-only remains the backstop for when even truncated content cannot fit. The overlap measurement had three faults: it summed flex-stretched boxes (always 'full', so it always wanted to collapse), it counted a Base UI hidden form element as 223px of phantom content, and the compiler-memoized useEffectEvent left observers reading the first render's null element forever. It now sums laid-out content (skipping hidden artifacts and the absolutely-positioned compact labels, which stay measurable at natural width), reads state through a render-synced ref, and applies hysteresis so the boundary cannot flap. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/BranchToolbar.tsx | 98 +++++++++++++------ .../BranchToolbarBranchSelector.tsx | 2 +- .../BranchToolbarEnvModeSelector.tsx | 7 +- .../BranchToolbarEnvironmentSelector.tsx | 5 +- .../components/settings/SettingsPanels.tsx | 28 +++++- 5 files changed, 102 insertions(+), 38 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 58d1b3a0cea8..1015d742fe56 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -9,7 +9,7 @@ import { HistoryIcon, MonitorIcon, } from "lucide-react"; -import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; @@ -215,45 +215,85 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ }); /** - * Collapse the strip's labels to icons only once they actually stop fitting. - * The natural width is remembered from the last expanded measurement, so the - * hidden labels cannot shrink the content and flip the decision straight back. + * Collapse the strip's labels to icons only when the text no longer fits. + * + * Hidden labels stay measurable (they collapse to invisible absolute boxes, + * which keep their natural width), so the required width can be recomputed in + * either state on every pass - no remembered widths that could go stale or + * latch the strip compact. A small hysteresis keeps the boundary from + * flapping between states. */ -function useLabelsOverflow(ref: RefObject): boolean { - const [overflows, setOverflows] = useState(false); - const naturalWidthRef = useRef(0); +const COMPACT_EXPAND_HYSTERESIS_PX = 16; - useEffect(() => { - const element = ref.current; - if (!element) return; +function useLabelsOverflow(element: HTMLDivElement | null): boolean { + const [overflows, setOverflows] = useState(false); + // A render-synced mirror instead of useEffectEvent: the compiler memoizes + // the event callback, which left observers reading the first render's null + // element forever. + const stateRef = useRef({ element, overflows }); + stateRef.current = { element, overflows }; - const measure = () => { - const available = element.clientWidth; - if (available === 0) return; - if (!overflows) { - // Labels truncate rather than overflow, so the strip's own scrollWidth - // never grows. Sum how much each label is clipped by instead: that - // deficit plus the current width is what the row really needs. - let deficit = 0; - for (const label of element.querySelectorAll("[data-composer-label]")) { - deficit += Math.max(0, label.scrollWidth - label.clientWidth); - } - naturalWidthRef.current = available + deficit; + const measure = useCallback(() => { + const { element: current, overflows: compact } = stateRef.current; + if (!current) return; + const available = current.clientWidth; + if (available === 0) return; + // flex-1 stretches the groups to fill the strip, so their own boxes always + // measure "full". Sum the laid-out content instead, skipping hidden form + // artifacts and absolutely-positioned nodes (the compact-hidden labels). + const contentWidth = (parent: Element): number => { + const gap = Number.parseFloat(getComputedStyle(parent).columnGap) || 0; + let width = 0; + let counted = 0; + for (const child of parent.children) { + if (!(child instanceof HTMLElement)) continue; + if (child.offsetWidth <= 1) continue; + const position = getComputedStyle(child).position; + if (position === "absolute" || position === "fixed") continue; + width += child.offsetWidth; + counted += 1; } - if (naturalWidthRef.current === 0) return; - setOverflows(naturalWidthRef.current > available + 1); + return width + gap * Math.max(0, counted - 1); }; + const stripGap = Number.parseFloat(getComputedStyle(current).columnGap) || 0; + let needed = 0; + let groups = 0; + for (const child of current.children) { + if (!(child instanceof HTMLElement) || child.offsetWidth <= 1) continue; + needed += contentWidth(child); + groups += 1; + } + needed += stripGap * Math.max(0, groups - 1); + for (const label of current.querySelectorAll("[data-composer-label]")) { + if (compact) { + // Compact: the label sits outside the flow at its natural width. + needed += label.offsetWidth; + } else { + // Expanded: the label is in flow but truncates; only the clipped + // remainder is missing from the content sum. + needed += Math.max(0, label.scrollWidth - label.clientWidth); + } + } + setOverflows(compact ? needed > available - COMPACT_EXPAND_HYSTERESIS_PX : needed > available); + }, []); + // Label widths can change without the strip box moving (font family or + // size preferences), so re-measure on every render as well as on resize + // and font loads. + useEffect(() => { measure(); + }); + + useEffect(() => { + if (!element) return; const observer = new ResizeObserver(measure); observer.observe(element); - // A font that finishes loading, or a size preference change, moves labels. document.fonts.addEventListener("loadingdone", measure); return () => { observer.disconnect(); document.fonts.removeEventListener("loadingdone", measure); }; - }, [overflows, ref]); + }, [element, measure]); return overflows; } @@ -344,14 +384,14 @@ export const BranchToolbar = memo(function BranchToolbar({ canPickEnvironment: showEnvironmentPicker, }); const isMobile = useIsMobile(); - const stripRef = useRef(null); - const labelsOverflow = useLabelsOverflow(stripRef); + const [stripElement, setStripElement] = useState(null); + const labelsOverflow = useLabelsOverflow(stripElement); if (!hasActiveThread || !activeProject) return null; return (
diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index bf6482b9acfa..c7d69545b9a3 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -751,7 +751,7 @@ export function BranchToolbarBranchSelector({ {triggerLabel} diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 7c975fb7477b..1164175f1545 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -82,7 +82,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe {effectiveEnvMode === "worktree" ? ( @@ -92,7 +92,10 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe ) : ( )} - + diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index f5b52b506741..bafca39c2b80 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -49,7 +49,10 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir ) : ( )} - + {activeEnvironment?.label ?? "Run on"} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 4a689e6b4df0..7323a8ba18fe 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -10,7 +10,7 @@ import { } from "lucide-react"; import { Link } from "@tanstack/react-router"; import type { CSSProperties, ReactNode } from "react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { defaultInstanceIdForDriver, @@ -1318,7 +1318,24 @@ function FontSizeSlider({ onChange: (value: number) => void; value: number; }) { - const ratio = (value - min) / (max - min); + const inputRef = useRef(null); + // The thumb and value label follow a local draft while dragging; the + // preference commits on the native change event (release). Applying it live + // would resize the interface - and this very slider - under the pointer. + const [draft, setDraft] = useState(null); + const shown = draft ?? value; + const commitDraft = useEffectEvent(() => { + if (draft !== null && draft !== value) onChange(draft); + setDraft(null); + }); + useEffect(() => { + const element = inputRef.current; + if (!element) return; + const handle = () => commitDraft(); + element.addEventListener("change", handle); + return () => element.removeEventListener("change", handle); + }, []); + const ratio = (shown - min) / (max - min); const style = { "--settings-slider-progress": `${ratio * 100}%`, "--settings-slider-fill-offset": `${0.5 - ratio}rem`, @@ -1326,21 +1343,22 @@ function FontSizeSlider({ return (
{ const next = Number(event.currentTarget.value); - if (Number.isInteger(next) && next >= min && next <= max) onChange(next); + if (Number.isInteger(next) && next >= min && next <= max) setDraft(next); }} step={1} style={style} type="range" - value={value} + value={shown} /> - {value} px + {shown} px
); From 7580c5d9e794265a7e46e9348cabf9ec06a42d73 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:05:40 +0200 Subject: [PATCH 27/47] feat(web): animate the context strip label collapse Labels slide shut (max-width to zero with a fade) instead of vanishing. Squeezed-shut labels still report their full text through scrollWidth, so the overflow measurement reads the same value in both states and stays transition-invariant - a mid-animation measurement cannot flip the decision. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/BranchToolbar.tsx | 5 +++-- apps/web/src/components/BranchToolbarBranchSelector.tsx | 2 +- apps/web/src/components/BranchToolbarEnvModeSelector.tsx | 2 +- apps/web/src/components/BranchToolbarEnvironmentSelector.tsx | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 1015d742fe56..92590b51a663 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -266,8 +266,9 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { needed += stripGap * Math.max(0, groups - 1); for (const label of current.querySelectorAll("[data-composer-label]")) { if (compact) { - // Compact: the label sits outside the flow at its natural width. - needed += label.offsetWidth; + // Compact: the label is squeezed to zero width, but scrollWidth still + // reports the full text it would need when expanded. + needed += label.scrollWidth; } else { // Expanded: the label is in flow but truncates; only the clipped // remainder is missing from the content sum. diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index c7d69545b9a3..bbd27f65ab0d 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -751,7 +751,7 @@ export function BranchToolbarBranchSelector({ {triggerLabel} diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 1164175f1545..ca778daad31c 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -94,7 +94,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe )} diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index bafca39c2b80..6ef4ca7a8287 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -51,7 +51,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir )} {activeEnvironment?.label ?? "Run on"} From c2677c2d90b5f68be9b06f4d32c2568d740d77f4 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:12:57 +0200 Subject: [PATCH 28/47] fix(web): consume the code size token, stop focus theft, gate terminal families Three review findings: - The code size preference set --font-size-code but nothing read it. Block code and highlighted chat blocks now take it; inline code stays relative to its sentence so it cannot tower over the surrounding prose. - The custom family field autofocused whenever it mounted, so opening Appearance with a persisted non-catalog family stole the keyboard. Focus now follows the deliberate act of picking Custom from the dropdown. - The terminal silently falls back from a proportional face, while the row still accepted and previewed one. The terminal row now refuses a custom family that is not monospace (flagging the field instead of pretending it took the value), and its preview renders the fallback the grid will actually draw. Co-Authored-By: Claude Fable 5 --- .../components/settings/SettingsPanels.tsx | 30 +++++++++++++++++-- apps/web/src/index.css | 12 ++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 7323a8ba18fe..e9c02490b975 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -116,6 +116,7 @@ import { availableFontOptions, fontOptionCategories, isFontFamilyAvailable, + isMonospaceFamily, type FontOption, } from "../../appearanceFonts"; import { @@ -1019,7 +1020,12 @@ export function AppearanceSettingsPanel() { ? appearanceFontStack(settings.fontFamilyComposer, sansStack) : sansStack; const codeStack = appearanceFontStack(settings.fontFamilyCode, DEFAULT_CODE_FONT_STACK); - const terminalStack = appearanceFontStack(settings.fontFamilyTerminal, DEFAULT_CODE_FONT_STACK); + // The renderer refuses proportional faces, so the preview must show the + // fallback the terminal will actually draw rather than the stored name. + const terminalStack = appearanceFontStack( + isMonospaceFamily(settings.fontFamilyTerminal) ? settings.fontFamilyTerminal : "", + DEFAULT_CODE_FONT_STACK, + ); const environmentStageLabel = useEnvironmentStageLabel(); const showEnvironmentIdentification = resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null; @@ -1273,6 +1279,7 @@ export function AppearanceSettingsPanel() { options={monoFontOptions} value={settings.fontFamilyTerminal} onValueChange={(fontFamilyTerminal) => updateSettings({ fontFamilyTerminal })} + requireMonospace size={{ label: "Terminal font size", min: MIN_TERMINAL_FONT_SIZE, @@ -1392,6 +1399,7 @@ function FontFamilySettingsRow({ preview, value, onValueChange, + requireMonospace = false, size, }: { title: string; @@ -1400,6 +1408,7 @@ function FontFamilySettingsRow({ preview: ReactNode; value: string; onValueChange: (value: string) => void; + requireMonospace?: boolean; size: { label: string; min: number; max: number; value: number; onChange: (v: number) => void }; }) { const trimmed = value.trim(); @@ -1429,9 +1438,13 @@ function FontFamilySettingsRow({ }, [], ); + const acceptsFamily = (candidate: string) => + isFontFamilyAvailable(candidate) && (!requireMonospace || isMonospaceFamily(candidate)); const commitDraft = (next: string) => { setDraftSettled(true); - if (next.trim().length === 0 || isFontFamilyAvailable(next)) { + // A rejected name stays in the field, flagged: the terminal would silently + // fall back to its default, so the row must not claim it took the value. + if (next.trim().length === 0 || acceptsFamily(next)) { onValueChange(next); } }; @@ -1441,6 +1454,16 @@ function FontFamilySettingsRow({ commitTimerRef.current = null; commitDraft(customDraft); }; + // Focus only when the user picked Custom from the dropdown. The field also + // appears for a persisted non-catalog family, and stealing focus on arrival + // would hijack the keyboard from whoever just opened Appearance. + const focusOnCustomEntry = useRef(false); + const customInputRef = useRef(null); + useEffect(() => { + if (!focusOnCustomEntry.current) return; + focusOnCustomEntry.current = false; + customInputRef.current?.focus(); + }); // Derived from the value, not just the picker state: client settings hydrate // after mount, so a persisted custom family must reveal the input on its own. const showCustomInput = customMode || (trimmed.length > 0 && !matchesOption); @@ -1483,9 +1506,9 @@ function FontFamilySettingsRow({ aria-invalid={draftPending || undefined} autoCapitalize="off" autoComplete="off" - autoFocus className="min-w-0 flex-1" maxLength={200} + ref={customInputRef} onBlur={flushDraft} onChange={(event) => { const next = event.currentTarget.value; @@ -1560,6 +1583,7 @@ function FontFamilySettingsRow({ setCustomDraft(""); setDraftSettled(true); setCustomMode(true); + focusOnCustomEntry.current = true; return; } setCustomMode(false); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 3452d284f740..b0bdab0b4ec1 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1018,6 +1018,18 @@ code { font-family: var(--font-mono); } +/* Code text carries the size preference too. Block code and highlighted chat + blocks take it directly; inline code stays relative to its sentence so it + never towers over the prose around it. */ +pre { + font-size: var(--font-size-code, inherit); +} + +.chat-markdown .chat-markdown-shiki .shiki, +.chat-markdown pre code { + font-size: var(--font-size-code, inherit); +} + /* @pierre/diffs surfaces (diffs, file previews, annotatable code, search lines) render inside shadow roots but consult these hooks with their own literal stacks as fallback. Custom properties inherit across the shadow From 805813ee799fad67ed26abd5e257d8311ec15469 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:20:16 +0200 Subject: [PATCH 29/47] fix(web): include the environment label in the collapse, gate the code family The interactive environment picker rendered a bare SelectValue, so the overflow measurement never saw it and it stayed full-width while its neighbours collapsed. It now carries the same label marker and transition. The Code row accepted any custom family, including proportional faces that break column alignment in code blocks, diffs, and file previews. It requires monospace now, matching Terminal. Co-Authored-By: Claude Fable 5 --- .../src/components/BranchToolbarEnvironmentSelector.tsx | 7 ++++++- apps/web/src/components/settings/SettingsPanels.tsx | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 6ef4ca7a8287..2cf99547752a 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -77,7 +77,12 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir ) : ( )} - + + + diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e9c02490b975..215bb9411e71 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1242,6 +1242,7 @@ export function AppearanceSettingsPanel() { options={monoFontOptions} value={settings.fontFamilyCode} onValueChange={(fontFamilyCode) => updateSettings({ fontFamilyCode })} + requireMonospace size={{ label: "Code font size", min: MIN_CODE_FONT_SIZE, From 5ce99c20bbc11541510915e01815c3bcd2680653 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:47:54 +0200 Subject: [PATCH 30/47] fix(web): commit slider steps reliably, scope pre sizing, measure nested labels Three review findings: - High: track clicks and keyboard steps fire input and change in one task, before any re-render, so the change handler read a stale draft and dropped the commit. The draft now mirrors into a ref written inside the input handler itself, so the commit reads the same turn's value. - The global pre font-size rule beat text-size utilities and inherited sizes on unrelated pre surfaces (terminal previews, approvals). The code size is now scoped to chat markdown code blocks; diffs and file previews already take it through --diffs-font-size. - SelectValue truncates internally, so the outer label marker's scrollWidth matched its clipped box and the workspace/environment labels fell out of the strip's overflow measurement. The text width is now the largest scrollWidth in the label subtree. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/BranchToolbar.tsx | 19 +++++++++----- .../components/settings/SettingsPanels.tsx | 25 +++++++++++++------ apps/web/src/index.css | 12 ++++----- 3 files changed, 36 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 92590b51a663..701609a155dd 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -265,14 +265,21 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { } needed += stripGap * Math.max(0, groups - 1); for (const label of current.querySelectorAll("[data-composer-label]")) { + // The clipping can happen below the marker (SelectValue truncates + // internally), where the outer span's scrollWidth matches its clipped + // box. The text's real width is the largest scrollWidth in the subtree. + let textWidth = label.scrollWidth; + for (const inner of label.querySelectorAll("*")) { + textWidth = Math.max(textWidth, inner.scrollWidth); + } if (compact) { - // Compact: the label is squeezed to zero width, but scrollWidth still - // reports the full text it would need when expanded. - needed += label.scrollWidth; + // Compact: the label is squeezed to zero width but keeps reporting + // the full width it would need when expanded. + needed += textWidth; } else { - // Expanded: the label is in flow but truncates; only the clipped - // remainder is missing from the content sum. - needed += Math.max(0, label.scrollWidth - label.clientWidth); + // Expanded: the label is in flow; only the clipped remainder is + // missing from the content sum. + needed += Math.max(0, textWidth - label.clientWidth); } } setOverflows(compact ? needed > available - COMPACT_EXPAND_HYSTERESIS_PX : needed > available); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 215bb9411e71..63c8b5e71bdd 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -10,7 +10,7 @@ import { } from "lucide-react"; import { Link } from "@tanstack/react-router"; import type { CSSProperties, ReactNode } from "react"; -import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { defaultInstanceIdForDriver, @@ -1332,14 +1332,22 @@ function FontSizeSlider({ // would resize the interface - and this very slider - under the pointer. const [draft, setDraft] = useState(null); const shown = draft ?? value; - const commitDraft = useEffectEvent(() => { - if (draft !== null && draft !== value) onChange(draft); - setDraft(null); - }); + // Refs, written in the same turn as the events: input and change can land in + // one task (track clicks, keyboard steps), before any re-render, so a + // state-reading closure would still see the previous draft and drop the + // commit. + const draftRef = useRef(null); + const latestRef = useRef({ value, onChange }); + latestRef.current = { value, onChange }; useEffect(() => { const element = inputRef.current; if (!element) return; - const handle = () => commitDraft(); + const handle = () => { + const next = draftRef.current; + draftRef.current = null; + setDraft(null); + if (next !== null && next !== latestRef.current.value) latestRef.current.onChange(next); + }; element.addEventListener("change", handle); return () => element.removeEventListener("change", handle); }, []); @@ -1358,7 +1366,10 @@ function FontSizeSlider({ min={min} onChange={(event) => { const next = Number(event.currentTarget.value); - if (Number.isInteger(next) && next >= min && next <= max) setDraft(next); + if (Number.isInteger(next) && next >= min && next <= max) { + draftRef.current = next; + setDraft(next); + } }} step={1} style={style} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index b0bdab0b4ec1..43b783bbb37f 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1018,13 +1018,11 @@ code { font-family: var(--font-mono); } -/* Code text carries the size preference too. Block code and highlighted chat - blocks take it directly; inline code stays relative to its sentence so it - never towers over the prose around it. */ -pre { - font-size: var(--font-size-code, inherit); -} - +/* Code blocks in chat carry the size preference. Scoped to chat markdown + rather than every pre: a global rule would beat text-size utilities and + inherited sizes on unrelated pre surfaces (terminal previews, approvals). + Inline code stays relative to its sentence so it never towers over prose; + diffs and file previews take the size through --diffs-font-size. */ .chat-markdown .chat-markdown-shiki .shiki, .chat-markdown pre code { font-size: var(--font-size-code, inherit); From a6f5f65b02698da22235afc1e02cbd88967d03fd Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:11:22 +0200 Subject: [PATCH 31/47] fix(web): apply the prompt-size floor only on touch devices The 16px focused-field floor exists for the touch-browser zoom quirk, which desktop browsers do not have - a narrow desktop window must not silently override a smaller chosen prompt size. Gate the media query on a coarse pointer. Co-Authored-By: Claude Fable 5 --- apps/web/src/index.css | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 43b783bbb37f..ca0416dba5b5 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1077,9 +1077,11 @@ code { font-size: var(--font-size-prompt, 0.875rem); } -/* Phone browsers zoom the page when a focused field is under 16px, so keep the - floor there regardless of the preference. */ -@media (max-width: 39.999rem) { +/* Touch browsers zoom the page when a focused field is under 16px, so keep + the floor there regardless of the preference. Gated on a coarse pointer: + the zoom quirk does not exist on desktop, where a narrow window must not + silently override a smaller chosen prompt size. */ +@media (max-width: 39.999rem) and (pointer: coarse) { .composer-editor-surface { font-size: max(var(--font-size-prompt, 1rem), 16px); } From a7b35a26d8525f6667ff3173fe206a762695b9dd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 4 Aug 2026 18:22:55 +0200 Subject: [PATCH 32/47] feat(web): render the font previews with the real surfaces The interface row loses its preview (the interface itself is the preview), the prompt row embeds the live composer editor, the code row renders the diff panel's file diff, and the terminal row mounts the Ghostty canvas renderer against a local echo loop. Co-Authored-By: Claude Fable 5 --- .../src/components/ThreadTerminalDrawer.tsx | 2 +- .../settings/SettingsFontPreviews.tsx | 205 ++++++++++++++++++ .../components/settings/SettingsPanels.tsx | 105 +-------- 3 files changed, 214 insertions(+), 98 deletions(-) create mode 100644 apps/web/src/components/settings/SettingsFontPreviews.tsx diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 9c30acfe6e62..d24a4b3db237 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -138,7 +138,7 @@ function terminalFontOptions(family: string, size: number): { family?: string; s return trimmed.length > 0 ? { family: trimmed, size } : { size }; } -function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { +export function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { const isDark = document.documentElement.classList.contains("dark"); const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx new file mode 100644 index 000000000000..552a379f7c1d --- /dev/null +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -0,0 +1,205 @@ +import { FileDiff } from "@pierre/diffs/react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "../ComposerPromptEditor"; +import { terminalThemeFromApp } from "../ThreadTerminalDrawer"; +import { useTheme } from "../../hooks/useTheme"; +import { + getRenderablePatch, + resolveDiffThemeName, + resolveFileDiffPath, +} from "../../lib/diffRendering"; +import { GhosttyTerminalSurface } from "~/terminal/ghostty/surface"; + +// The font previews are the real surfaces, not lookalikes: the composer's +// Lexical editor, the diff panel's file diff, and the Ghostty canvas +// renderer. Each already consumes the appearance font tokens (or, for the +// terminal, the settings passed down as props), so what the row shows is +// exactly what the app renders. + +const EMPTY_TERMINAL_CONTEXTS: ReadonlyArray = []; +const EMPTY_SKILLS: ReadonlyArray = []; + +const PROMPT_PREVIEW_TEXT = "Fix the flaky test in surface.test.ts and explain the race."; + +function noop() {} + +/** A live composer editor: type in it to feel the family and size. */ +export function PromptFontPreview() { + const editorRef = useRef(null); + const [prompt, setPrompt] = useState(PROMPT_PREVIEW_TEXT); + const [cursor, setCursor] = useState(PROMPT_PREVIEW_TEXT.length); + const onChange = useCallback((nextValue: string, nextCursor: number) => { + setPrompt(nextValue); + setCursor(nextCursor); + }, []); + return ( +
+ +
+ ); +} + +const DIFF_PREVIEW_PATCH = [ + "diff --git a/src/formatUser.ts b/src/formatUser.ts", + "--- a/src/formatUser.ts", + "+++ b/src/formatUser.ts", + "@@ -1,3 +1,3 @@", + " export function formatUser(user: User) {", + "- return user.name.toUpperCase();", + "+ return `${user.name} <${user.email}>`; // 0O 1lI", + " }", + "", +].join("\n"); + +/** + * The diff panel's file diff, rendered by its real pipeline. The one-off + * worker pool is not worth it for a three-line patch, so the diff renders on + * the main thread. + */ +export function CodeFontPreview() { + const { resolvedTheme } = useTheme(); + const renderablePatch = useMemo( + () => getRenderablePatch(DIFF_PREVIEW_PATCH, "settings-font-preview"), + [], + ); + if (renderablePatch?.kind !== "files") return null; + return ( +
+ {renderablePatch.files.map((fileDiff) => ( + + ))} +
+ ); +} + +const TERMINAL_PROMPT = "\x1b[2m$\x1b[0m "; +const TERMINAL_PREVIEW_TRANSCRIPT = + `${TERMINAL_PROMPT}npm run dev\r\n` + + "\x1b[32m✓\x1b[0m Ready in 430ms\r\n" + + "\x1b[2mLocal:\x1b[0m \x1b[36mhttp://localhost:3000\x1b[0m\r\n" + + TERMINAL_PROMPT; + +/** The surface treats an omitted family or size as "use the built-in default". */ +function previewTerminalFont(family: string, size: number): { family?: string; size: number } { + const trimmed = family.trim(); + return trimmed.length > 0 ? { family: trimmed, size } : { size }; +} + +/** + * The real Ghostty canvas renderer against a local echo loop instead of a + * PTY: keys print, Enter starts a new prompt line, Backspace erases. That + * exercises the same glyph atlas, cell metrics, and monospace gate the + * terminal drawer uses. + */ +export function TerminalFontPreview({ family, size }: { family: string; size: number }) { + const mountRef = useRef(null); + const surfaceRef = useRef(null); + const fontRef = useRef({ family, size }); + const { resolvedTheme } = useTheme(); + + useEffect(() => { + const current = fontRef.current; + if (current.family === family && current.size === size) return; + fontRef.current = { family, size }; + void surfaceRef.current?.setFont(previewTerminalFont(family, size)); + }, [family, size]); + + useEffect(() => { + const mount = mountRef.current; + const surface = surfaceRef.current; + if (!mount || !surface) return; + surface.setTheme(terminalThemeFromApp(mount)); + }, [resolvedTheme]); + + useEffect(() => { + const mount = mountRef.current; + if (!mount) return; + let cancelled = false; + // Column of the caret on the current input line, so Backspace stops at + // the prompt instead of eating it. + let lineLength = 0; + + const echo = (data: string) => { + const surface = surfaceRef.current; + if (!surface) return; + if (data === "\r") { + surface.write(`\r\n${TERMINAL_PROMPT}`); + lineLength = 0; + return; + } + if (data === "\x7f" || data === "\b") { + if (lineLength > 0) { + surface.write("\b \b"); + lineLength -= 1; + } + return; + } + // Arrow keys and other escape reports have no cursor to move here. + if (data.startsWith("\x1b")) return; + const printable = [...data] + .filter((character) => character >= " " && character !== "\x7f") + .join(""); + if (printable.length === 0) return; + surface.write(printable); + lineLength += printable.length; + }; + + void GhosttyTerminalSurface.create(mount, { + theme: terminalThemeFromApp(mount), + font: previewTerminalFont(fontRef.current.family, fontRef.current.size), + onData: echo, + onResize: noop, + onSelectionChange: noop, + onCopy: (text) => void navigator.clipboard?.writeText(text).catch(noop), + // Tab keeps walking the settings page instead of feeding the echo loop. + beforeKey: (event) => event.key !== "Tab", + onLinkActivate: noop, + }).then((surface) => { + if (cancelled) { + surface.dispose(); + return; + } + surfaceRef.current = surface; + // The theme and font may both have changed while the WASM surface loaded. + surface.setTheme(terminalThemeFromApp(mount)); + const font = fontRef.current; + void surface.setFont(previewTerminalFont(font.family, font.size)); + surface.write(TERMINAL_PREVIEW_TRANSCRIPT); + }); + + return () => { + cancelled = true; + surfaceRef.current?.dispose(); + surfaceRef.current = null; + }; + }, []); + + return ( +
+ ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 22bd54bba906..43e4d1f82df7 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -108,17 +108,15 @@ import { import { DraftInput } from "../ui/draft-input"; import { Input } from "../ui/input"; import { - DEFAULT_CODE_FONT_STACK, - DEFAULT_SANS_FONT_STACK, MONO_FONT_OPTIONS, SANS_FONT_OPTIONS, - appearanceFontStack, availableFontOptions, fontOptionCategories, isFontFamilyAvailable, isMonospaceFamily, type FontOption, } from "../../appearanceFonts"; +import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; import { NumberField, NumberFieldDecrement, @@ -1013,20 +1011,6 @@ export function AppearanceSettingsPanel() { [], ); const monoFontOptions = useMemo(() => availableFontOptions(MONO_FONT_OPTIONS), []); - const sansStack = appearanceFontStack(settings.fontFamilySans, DEFAULT_SANS_FONT_STACK); - // The composer falls back to the resolved interface stack (not the bare - // default) so the preview matches the runtime var(--font-composer) chain. - const composerStack = - settings.fontFamilyComposer.trim().length > 0 - ? appearanceFontStack(settings.fontFamilyComposer, sansStack) - : sansStack; - const codeStack = appearanceFontStack(settings.fontFamilyCode, DEFAULT_CODE_FONT_STACK); - // The renderer refuses proportional faces, so the preview must show the - // fallback the terminal will actually draw rather than the stored name. - const terminalStack = appearanceFontStack( - isMonospaceFamily(settings.fontFamilyTerminal) ? settings.fontFamilyTerminal : "", - DEFAULT_CODE_FONT_STACK, - ); const environmentStageLabel = useEnvironmentStageLabel(); const showEnvironmentIdentification = resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null; @@ -1200,16 +1184,6 @@ export function AppearanceSettingsPanel() { value: settings.fontSizeInterface, onChange: (fontSizeInterface) => updateSettings({ fontSizeInterface }), }} - preview={ - -

- The quick brown fox jumps over the lazy dog. -

-

- Messages, labels, and headings across the app. -

-
- } /> updateSettings({ fontSizePrompt }), }} - preview={ - -
-

- Fix the flaky test in surface.test.ts and explain the race. -

-

- Ask for follow-up changes or attach images -

-
-
- } + preview={} /> updateSettings({ fontSizeCode }), }} - preview={ - -
-                
-                  1
-                  {"  "}
-                  function{" "}
-                  formatUser
-                  (user) {"{"}
-                  {"\n"}
-                  2
-                  {"    "}
-                  return{" "}
-                  {"`${user.name} <${user.email}>`"}{" "}
-                  {"// 0O 1lI"}
-                  {"\n"}
-                  3
-                  {"  "}
-                  {"}"}
-                
-              
-
- } + preview={} /> updateSettings({ fontSizeTerminal }), }} preview={ - -
-                
-                  ${" "}
-                  npm run dev
-                  {"\n"}
-                  {"\u2713"} Ready in 430ms
-                  {"\n"}
-                  Local:{" "}
-                  http://localhost:3000
-                
-              
-
+ } /> @@ -1384,27 +1316,6 @@ function FontSizeSlider({ ); } -/** Renders in the family and size the surface will actually use. */ -function FontPreviewCard({ - children, - stack, - size, -}: { - children: ReactNode; - stack: string; - size: number; -}) { - return ( -
- {children} -
- ); -} - function FontFamilySettingsRow({ title, description, @@ -1418,7 +1329,7 @@ function FontFamilySettingsRow({ title: string; description: string; options: readonly FontOption[]; - preview: ReactNode; + preview?: ReactNode; value: string; onValueChange: (value: string) => void; requireMonospace?: boolean; From 62daf23f5933f190c7a16047b79507504dd69f74 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 4 Aug 2026 18:56:25 +0200 Subject: [PATCH 33/47] feat(web): inline font rows and system-wide font pickers Each font row holds a family picker and a px dropdown side by side, with the live preview full-width underneath. The family picker enumerates every installed font through the Local Font Access API (searchable, virtualized, monospace-gated for grid surfaces); engines without the API get a plain validated name input instead of the old curated catalog, which leaves with this commit. The desktop preview browser session now grants local-fonts so embedded T3 tabs can enumerate too. Co-Authored-By: Claude Fable 5 --- apps/desktop/src/preview/BrowserSession.ts | 4 + apps/web/src/appearanceFonts.test.ts | 19 - apps/web/src/appearanceFonts.ts | 113 ++-- .../components/settings/FontFamilyPicker.tsx | 177 ++++++ .../components/settings/SettingsPanels.tsx | 506 ++++++------------ 5 files changed, 386 insertions(+), 433 deletions(-) create mode 100644 apps/web/src/components/settings/FontFamilyPicker.tsx diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index aa0b0743e933..d26f9eb634a9 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -23,6 +23,10 @@ const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ "clipboard-sanitized-write", "notifications", "geolocation", + // Local Font Access (queryLocalFonts). Both handlers again: the API + // consults the permission check before ever raising a request, and a + // denied check resolves with an empty font list rather than an error. + "local-fonts", ]); export class BrowserSessionPartitionDerivationError extends Schema.TaggedErrorClass()( diff --git a/apps/web/src/appearanceFonts.test.ts b/apps/web/src/appearanceFonts.test.ts index 616ab7bd86a2..3f18e65facf4 100644 --- a/apps/web/src/appearanceFonts.test.ts +++ b/apps/web/src/appearanceFonts.test.ts @@ -6,11 +6,8 @@ import { clampPromptFontSize, DEFAULT_CODE_FONT_STACK, DEFAULT_SANS_FONT_STACK, - MONO_FONT_OPTIONS, - SANS_FONT_OPTIONS, appearanceFontStack, cssFontFamilies, - fontOptionCategories, } from "./appearanceFonts"; describe("cssFontFamilies", () => { @@ -37,22 +34,6 @@ describe("cssFontFamilies", () => { }); }); -describe("fontOptionCategories", () => { - it("splits a mixed list into labeled sections preserving catalog order", () => { - const mixed = [...SANS_FONT_OPTIONS.slice(0, 2), ...MONO_FONT_OPTIONS.slice(0, 2)]; - const sections = fontOptionCategories(mixed); - expect(sections.map(([category]) => category)).toEqual(["Sans serif", "Monospace"]); - expect(sections[0]?.[1]).toEqual(SANS_FONT_OPTIONS.slice(0, 2)); - expect(sections[1]?.[1]).toEqual(MONO_FONT_OPTIONS.slice(0, 2)); - }); - - it("keeps a single-category list in one unlabeled-ready section", () => { - const sections = fontOptionCategories(MONO_FONT_OPTIONS); - expect(sections).toHaveLength(1); - expect(sections[0]?.[0]).toBe("Monospace"); - }); -}); - describe("appearanceFontStack", () => { it("prepends the custom family to the default stack", () => { expect(appearanceFontStack("Fira Code", DEFAULT_CODE_FONT_STACK)).toBe( diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 86939a1316c2..44dd5147f2de 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -117,70 +117,6 @@ export function clampCodeFontSize(value: number): number { return clampFontSize(value, MIN_CODE_FONT_SIZE, MAX_CODE_FONT_SIZE, DEFAULT_CODE_FONT_SIZE); } -export type FontCategory = "Sans serif" | "Monospace"; - -export interface FontOption { - readonly label: string; - readonly family: string; - readonly category: FontCategory; -} - -function fontCatalog( - category: FontCategory, - entries: ReadonlyArray>, -): readonly FontOption[] { - return entries.map((entry) => ({ ...entry, category })); -} - -/** - * Curated choices for the Appearance dropdowns. The settings UI filters these - * through `isFontFamilyAvailable`, so platforms only offer faces that will - * actually render; "Custom" in the UI covers everything else. The category - * groups mixed dropdowns (composer) into labeled sections. - */ -export const SANS_FONT_OPTIONS: readonly FontOption[] = fontCatalog("Sans serif", [ - // The bundled webfont registers as "DM Sans Variable", not "DM Sans"; the - // option must reference the registered name to resolve on every machine. - { label: "DM Sans", family: "DM Sans Variable" }, - { label: "Inter", family: "Inter" }, - { label: "SF Pro", family: "SF Pro Text" }, - { label: "Segoe UI", family: "Segoe UI" }, - { label: "Roboto", family: "Roboto" }, - { label: "Helvetica Neue", family: "Helvetica Neue" }, - { label: "Arial", family: "Arial" }, - { label: "System UI", family: "system-ui" }, -]); - -export const MONO_FONT_OPTIONS: readonly FontOption[] = fontCatalog("Monospace", [ - { label: "SF Mono", family: "SF Mono" }, - { label: "JetBrains Mono", family: "JetBrains Mono" }, - { label: "Fira Code", family: "Fira Code" }, - { label: "Cascadia Code", family: "Cascadia Code" }, - { label: "Menlo", family: "Menlo" }, - { label: "Monaco", family: "Monaco" }, - { label: "Consolas", family: "Consolas" }, - { label: "Source Code Pro", family: "Source Code Pro" }, - { label: "IBM Plex Mono", family: "IBM Plex Mono" }, - { label: "Ubuntu Mono", family: "Ubuntu Mono" }, - { label: "Courier New", family: "Courier New" }, -]); - -/** The options split into their labeled category sections, in catalog order. */ -export function fontOptionCategories( - options: readonly FontOption[], -): ReadonlyArray { - const sections = new Map(); - for (const option of options) { - const section = sections.get(option.category); - if (section === undefined) { - sections.set(option.category, [option]); - } else { - section.push(option); - } - } - return [...sections.entries()]; -} - const FONT_PROBE_TEXT = "mmmmmmmmMMWli1O0@# fjord"; let fontProbeContext: CanvasRenderingContext2D | null | undefined; @@ -245,11 +181,48 @@ export function isMonospaceFamily(family: string): boolean { } } -/** Webfonts the app bundles; offered even before document.fonts has loaded them. */ -const BUNDLED_FAMILIES = new Set(["DM Sans Variable", "JetBrains Mono"]); +export interface InstalledFontFamiliesResult { + readonly families: readonly string[]; + /** + * "unsupported" - the engine has no Local Font Access API (Safari, + * Firefox); "denied" - the API exists but the user declined the permission + * prompt. Both fall back to the curated catalog. + */ + readonly status: "granted" | "denied" | "unsupported"; +} -export function availableFontOptions(options: readonly FontOption[]): readonly FontOption[] { - return options.filter( - (option) => BUNDLED_FAMILIES.has(option.family) || isFontFamilyAvailable(option.family), - ); +let installedFamiliesCache: InstalledFontFamiliesResult | null = null; + +/** + * Every installed family via the Local Font Access API (Chromium and + * Electron). Call from a user gesture: the first call raises the browser's + * local-fonts permission prompt. A denial is not cached, so reopening the + * picker can ask again after the user changes the site setting. + */ +export async function queryInstalledFontFamilies(): Promise { + if (installedFamiliesCache !== null) return installedFamiliesCache; + const query = ( + window as Window & { + queryLocalFonts?: () => Promise>; + } + ).queryLocalFonts; + if (typeof query !== "function") { + installedFamiliesCache = { families: [], status: "unsupported" }; + return installedFamiliesCache; + } + try { + const fonts = await query.call(window); + const families = [...new Set(fonts.map((font) => font.family))] + // Dot-prefixed families are macOS-internal UI faces; selecting one is + // never intended and most refuse to render for web content anyway. + .filter((family) => !family.startsWith(".")) + .sort((left, right) => left.localeCompare(right)); + // A denied permission check resolves with an empty list instead of + // throwing; no machine has zero fonts, so treat empty as denied. + if (families.length === 0) return { families: [], status: "denied" }; + installedFamiliesCache = { families, status: "granted" }; + return installedFamiliesCache; + } catch { + return { families: [], status: "denied" }; + } } diff --git a/apps/web/src/components/settings/FontFamilyPicker.tsx b/apps/web/src/components/settings/FontFamilyPicker.tsx new file mode 100644 index 000000000000..b890ae7acf2e --- /dev/null +++ b/apps/web/src/components/settings/FontFamilyPicker.tsx @@ -0,0 +1,177 @@ +import { LegendList } from "@legendapp/list/react"; +import { CheckIcon, ChevronDownIcon, SearchIcon } from "lucide-react"; +import { useMemo, useState } from "react"; +import { + type InstalledFontFamiliesResult, + isMonospaceFamily, + queryInstalledFontFamilies, +} from "../../appearanceFonts"; +import { + Combobox, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxListVirtualized, + ComboboxPopup, + ComboboxTrigger, +} from "../ui/combobox"; + +const DEFAULT_FONT_VALUE = "__default__"; + +/** + * Whether the engine can enumerate installed fonts (Local Font Access API - + * Chromium and Electron). Rows fall back to a plain family-name input + * elsewhere. + */ +export function supportsFontEnumeration(): boolean { + return ( + typeof window !== "undefined" && + typeof (window as { queryLocalFonts?: unknown }).queryLocalFonts === "function" + ); +} + +/** + * A searchable picker over every installed family, the way native editors + * list system fonts. Enumeration happens on open - that click is the user + * gesture the local-fonts permission prompt requires. + */ +export function FontFamilyPicker({ + ariaLabel, + selectedFamily, + requireMonospace = false, + onSelect, +}: { + ariaLabel: string; + /** Committed family name; empty string means the built-in default. */ + selectedFamily: string; + requireMonospace?: boolean; + onSelect: (family: string) => void; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [installed, setInstalled] = useState(null); + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) return; + setQuery(""); + if (installed?.status !== "granted") { + void queryInstalledFontFamilies().then(setInstalled); + } + }; + + const families = useMemo(() => { + if (installed?.status !== "granted") return []; + return requireMonospace ? installed.families.filter(isMonospaceFamily) : installed.families; + }, [installed, requireMonospace]); + + const items = useMemo(() => { + const trimmedQuery = query.trim().toLowerCase(); + const result: string[] = []; + if (trimmedQuery.length === 0) result.push(DEFAULT_FONT_VALUE); + result.push( + ...families.filter( + (family) => trimmedQuery.length === 0 || family.toLowerCase().includes(trimmedQuery), + ), + ); + return result; + }, [query, families]); + + const selectedValue = selectedFamily.length === 0 ? DEFAULT_FONT_VALUE : selectedFamily; + + const handlePick = (value: string) => { + setOpen(false); + onSelect(value === DEFAULT_FONT_VALUE ? "" : value); + }; + + const renderItem = (item: string, index: number) => ( + handlePick(item)} + > +
+ + {item === DEFAULT_FONT_VALUE ? "Default" : item} + + {item === selectedValue ? ( + + ) : null} +
+
+ ); + + const statusNotice = + installed?.status === "denied" + ? "Font access was declined in the browser." + : open && installed === null + ? "Reading installed fonts…" + : null; + + return ( + + + + {selectedFamily.length === 0 ? "Default" : selectedFamily} + + + + +
+
+
+
+
+ No fonts found. +
+ + + data={items} + keyExtractor={(item) => item} + renderItem={({ item, index }) => renderItem(item, index)} + estimatedItemSize={30} + drawDistance={360} + style={{ height: Math.min(items.length * 30, 288) }} + /> + +
+ {statusNotice ? ( +
+ {statusNotice} +
+ ) : null} +
+
+
+ ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 43e4d1f82df7..ee75750c2eb3 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2,7 +2,6 @@ import { ArchiveIcon, ArchiveX, InfoIcon, - ListIcon, LoaderIcon, PlusIcon, RefreshCwIcon, @@ -107,16 +106,9 @@ import { } from "../ui/dialog"; import { DraftInput } from "../ui/draft-input"; import { Input } from "../ui/input"; -import { - MONO_FONT_OPTIONS, - SANS_FONT_OPTIONS, - availableFontOptions, - fontOptionCategories, - isFontFamilyAvailable, - isMonospaceFamily, - type FontOption, -} from "../../appearanceFonts"; +import { isFontFamilyAvailable, isMonospaceFamily } from "../../appearanceFonts"; import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; +import { FontFamilyPicker, supportsFontEnumeration } from "./FontFamilyPicker"; import { NumberField, NumberFieldDecrement, @@ -124,15 +116,7 @@ import { NumberFieldIncrement, NumberFieldInput, } from "../ui/number-field"; -import { - Select, - SelectGroup, - SelectGroupLabel, - SelectItem, - SelectPopup, - SelectTrigger, - SelectValue, -} from "../ui/select"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -1002,15 +986,6 @@ export function AppearanceSettingsPanel() { const { theme, setTheme } = useTheme(); const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); - // Every dropdown offers the full catalog, split into category sections, so - // any surface can point at any face - the categories carry the guidance. - // Text surfaces take any face; the terminal and code render on a fixed cell - // grid, where a proportional font cannot line up, so they stay monospace. - const textFontOptions = useMemo( - () => [...availableFontOptions(SANS_FONT_OPTIONS), ...availableFontOptions(MONO_FONT_OPTIONS)], - [], - ); - const monoFontOptions = useMemo(() => availableFontOptions(MONO_FONT_OPTIONS), []); const environmentStageLabel = useEnvironmentStageLabel(); const showEnvironmentIdentification = resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null; @@ -1171,155 +1146,86 @@ export function AppearanceSettingsPanel() { - updateSettings({ fontFamilySans })} - size={{ - label: "Interface font size", - min: MIN_INTERFACE_FONT_SIZE, - max: MAX_INTERFACE_FONT_SIZE, - value: settings.fontSizeInterface, - onChange: (fontSizeInterface) => updateSettings({ fontSizeInterface }), - }} - /> - updateSettings({ fontFamilyComposer })} - size={{ - label: "Prompt font size", - min: MIN_PROMPT_FONT_SIZE, - max: MAX_PROMPT_FONT_SIZE, - value: settings.fontSizePrompt, - onChange: (fontSizePrompt) => updateSettings({ fontSizePrompt }), - }} - preview={} - /> - updateSettings({ fontFamilyCode })} - requireMonospace - size={{ - label: "Code font size", - min: MIN_CODE_FONT_SIZE, - max: MAX_CODE_FONT_SIZE, - value: settings.fontSizeCode, - onChange: (fontSizeCode) => updateSettings({ fontSizeCode }), - }} - preview={} - /> - updateSettings({ fontFamilyTerminal })} - requireMonospace - size={{ - label: "Terminal font size", - min: MIN_TERMINAL_FONT_SIZE, - max: MAX_TERMINAL_FONT_SIZE, - value: settings.fontSizeTerminal, - onChange: (fontSizeTerminal) => updateSettings({ fontSizeTerminal }), - }} - preview={ - - } - /> + ); } -const CUSTOM_FONT_VALUE = "__custom__"; -const DEFAULT_FONT_VALUE = "__default__"; - -/** Mirrors the mobile Appearance sliders: a labelled value and a filled track. */ -function FontSizeSlider({ - label, - max, - min, - onChange, - value, -}: { - label: string; - max: number; - min: number; - onChange: (value: number) => void; - value: number; -}) { - const inputRef = useRef(null); - // The thumb and value label follow a local draft while dragging; the - // preference commits on the native change event (release). Applying it live - // would resize the interface - and this very slider - under the pointer. - const [draft, setDraft] = useState(null); - const shown = draft ?? value; - // Refs, written in the same turn as the events: input and change can land in - // one task (track clicks, keyboard steps), before any re-render, so a - // state-reading closure would still see the previous draft and drop the - // commit. - const draftRef = useRef(null); - const latestRef = useRef({ value, onChange }); - latestRef.current = { value, onChange }; - useEffect(() => { - const element = inputRef.current; - if (!element) return; - const handle = () => { - const next = draftRef.current; - draftRef.current = null; - setDraft(null); - if (next !== null && next !== latestRef.current.value) latestRef.current.onChange(next); - }; - element.addEventListener("change", handle); - return () => element.removeEventListener("change", handle); - }, []); - const ratio = (shown - min) / (max - min); - const style = { - "--settings-slider-progress": `${ratio * 100}%`, - "--settings-slider-fill-offset": `${0.5 - ratio}rem`, - } as CSSProperties; +function FontSettingsGroup() { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); return ( -
- { - const next = Number(event.currentTarget.value); - if (Number.isInteger(next) && next >= min && next <= max) { - draftRef.current = next; - setDraft(next); - } + <> + updateSettings({ fontFamilySans })} + size={{ + label: "Interface font size", + min: MIN_INTERFACE_FONT_SIZE, + max: MAX_INTERFACE_FONT_SIZE, + value: settings.fontSizeInterface, + onChange: (fontSizeInterface) => updateSettings({ fontSizeInterface }), }} - step={1} - style={style} - type="range" - value={shown} /> - - {shown} px - -
+ updateSettings({ fontFamilyComposer })} + size={{ + label: "Prompt font size", + min: MIN_PROMPT_FONT_SIZE, + max: MAX_PROMPT_FONT_SIZE, + value: settings.fontSizePrompt, + onChange: (fontSizePrompt) => updateSettings({ fontSizePrompt }), + }} + preview={} + /> + updateSettings({ fontFamilyCode })} + requireMonospace + size={{ + label: "Code font size", + min: MIN_CODE_FONT_SIZE, + max: MAX_CODE_FONT_SIZE, + value: settings.fontSizeCode, + onChange: (fontSizeCode) => updateSettings({ fontSizeCode }), + }} + preview={} + /> + updateSettings({ fontFamilyTerminal })} + requireMonospace + size={{ + label: "Terminal font size", + min: MIN_TERMINAL_FONT_SIZE, + max: MAX_TERMINAL_FONT_SIZE, + value: settings.fontSizeTerminal, + onChange: (fontSizeTerminal) => updateSettings({ fontSizeTerminal }), + }} + preview={ + + } + /> + ); } function FontFamilySettingsRow({ title, description, - options, preview, value, onValueChange, @@ -1328,7 +1234,6 @@ function FontFamilySettingsRow({ }: { title: string; description: string; - options: readonly FontOption[]; preview?: ReactNode; value: string; onValueChange: (value: string) => void; @@ -1336,24 +1241,22 @@ function FontFamilySettingsRow({ size: { label: string; min: number; max: number; value: number; onChange: (v: number) => void }; }) { const trimmed = value.trim(); - const matchesOption = options.some((option) => option.family === trimmed); - const [customMode, setCustomMode] = useState(false); - // The custom input edits a draft; the preference only commits once typing + // The fallback input edits a draft; the preference only commits once typing // pauses and the text probes as an available font (or is an explicit // clear), so the current font holds and nothing reflows mid-word. - const [customDraft, setCustomDraft] = useState(value); + const [draft, setDraft] = useState(value); const [draftSettled, setDraftSettled] = useState(true); const commitTimerRef = useRef(null); const lastValueRef = useRef(value); if (lastValueRef.current !== value) { - // The committed value changed externally (hydration, reset, dropdown - // pick); adopt it and drop any pending commit of a stale draft. + // The committed value changed externally (hydration, reset, picker + // selection); adopt it and drop any pending commit of a stale draft. lastValueRef.current = value; if (commitTimerRef.current !== null) { window.clearTimeout(commitTimerRef.current); commitTimerRef.current = null; } - setCustomDraft(value); + setDraft(value); setDraftSettled(true); } useEffect( @@ -1376,186 +1279,101 @@ function FontFamilySettingsRow({ if (commitTimerRef.current === null) return; window.clearTimeout(commitTimerRef.current); commitTimerRef.current = null; - commitDraft(customDraft); + commitDraft(draft); }; - // Focus only when the user picked Custom from the dropdown. The field also - // appears for a persisted non-catalog family, and stealing focus on arrival - // would hijack the keyboard from whoever just opened Appearance. - const focusOnCustomEntry = useRef(false); - const customInputRef = useRef(null); - useEffect(() => { - if (!focusOnCustomEntry.current) return; - focusOnCustomEntry.current = false; - customInputRef.current?.focus(); - }); - // Derived from the value, not just the picker state: client settings hydrate - // after mount, so a persisted custom family must reveal the input on its own. - const showCustomInput = customMode || (trimmed.length > 0 && !matchesOption); - const draftTrimmed = customDraft.trim(); + const draftTrimmed = draft.trim(); // Flag an unknown name only once typing pauses, and never for an empty // field - that is the starting state, not a rejected entry. - const draftPending = - draftSettled && showCustomInput && draftTrimmed.length > 0 && draftTrimmed !== trimmed; - const categories = fontOptionCategories(options); - const selected = - trimmed.length === 0 && !customMode - ? DEFAULT_FONT_VALUE - : customMode || !matchesOption - ? CUSTOM_FONT_VALUE - : trimmed; + const draftPending = draftSettled && draftTrimmed.length > 0 && draftTrimmed !== trimmed; + const resetAction = + trimmed.length > 0 ? ( + onValueChange("")} + /> + ) : null; + const familyControl = supportsFontEnumeration() ? ( + + ) : ( + { + const next = event.currentTarget.value; + setDraft(next); + setDraftSettled(false); + if (commitTimerRef.current !== null) { + window.clearTimeout(commitTimerRef.current); + } + commitTimerRef.current = window.setTimeout(() => { + commitTimerRef.current = null; + commitDraft(next); + }, 400); + }} + onKeyDown={(event) => { + if (event.key === "Enter") flushDraft(); + if (event.key === "Escape") { + // Discard uncommitted typing without closing the settings page, + // which is what an unhandled Escape does. + event.preventDefault(); + event.stopPropagation(); + if (commitTimerRef.current !== null) { + window.clearTimeout(commitTimerRef.current); + commitTimerRef.current = null; + } + setDraft(value); + setDraftSettled(true); + } + }} + placeholder="Font family name" + spellCheck={false} + value={draft} + /> + ); + const control = ( +
+
{familyControl}
+ +
+ ); return ( 0 || customMode ? ( - { - setCustomMode(false); - onValueChange(""); - }} - /> - ) : null - } - control={ -
- {/* The custom input replaces the dropdown rather than stacking under - it, so entering custom mode does not change the row height. */} -
- {showCustomInput ? ( - <> - { - const next = event.currentTarget.value; - setCustomDraft(next); - setDraftSettled(false); - if (commitTimerRef.current !== null) { - window.clearTimeout(commitTimerRef.current); - } - commitTimerRef.current = window.setTimeout(() => { - commitTimerRef.current = null; - commitDraft(next); - }, 400); - }} - onKeyDown={(event) => { - if (event.key === "Enter") flushDraft(); - if (event.key === "Escape") { - // Discard uncommitted typing without leaving the settings - // page (Escape closes it), and drop back to the list only - // when there is no committed family to return to. - event.preventDefault(); - event.stopPropagation(); - if (commitTimerRef.current !== null) { - window.clearTimeout(commitTimerRef.current); - commitTimerRef.current = null; - } - setCustomDraft(value); - setDraftSettled(true); - if (trimmed.length === 0) setCustomMode(false); - } - }} - placeholder="Font family name" - spellCheck={false} - value={customDraft} - /> - - { - setCustomMode(false); - onValueChange(""); - }} - size="icon-sm" - variant="ghost" - > - - - } - /> - Choose from the list - - - ) : ( - - )} -
- -
- } + resetAction={resetAction} + control={control} > {preview}
From 4b728a7b5c65552dd4d60460f0f29aac4a18fe35 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 4 Aug 2026 18:57:45 +0200 Subject: [PATCH 34/47] fix(web): title the fonts section Typography and move word wrap into it Co-Authored-By: Claude Fable 5 --- apps/web/src/components/settings/SettingsPanels.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index ee75750c2eb3..a757ea823736 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1119,7 +1119,10 @@ export function AppearanceSettingsPanel() { } /> ) : null} + + + - - - - ); } From dd9700fbb7618d399a9a4cbe795e14d401bc5728 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 4 Aug 2026 19:00:26 +0200 Subject: [PATCH 35/47] fix(web): register the font rows in settings search Co-Authored-By: Claude Fable 5 --- .../components/settings/SettingsPanels.tsx | 11 ++++++---- .../src/components/settings/settingsSearch.ts | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index a757ea823736..ca38965e5f01 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1157,7 +1157,7 @@ function FontSettingsGroup() { return ( <> updateSettings({ fontFamilySans })} @@ -1170,7 +1170,7 @@ function FontSettingsGroup() { }} /> updateSettings({ fontFamilyComposer })} @@ -1184,7 +1184,7 @@ function FontSettingsGroup() { preview={} /> updateSettings({ fontFamilyCode })} @@ -1199,7 +1199,7 @@ function FontSettingsGroup() { preview={} /> updateSettings({ fontFamilyTerminal })} @@ -1223,6 +1223,7 @@ function FontSettingsGroup() { } function FontFamilySettingsRow({ + id, title, description, preview, @@ -1231,6 +1232,7 @@ function FontFamilySettingsRow({ requireMonospace = false, size, }: { + id?: string; title: string; description: string; preview?: ReactNode; @@ -1369,6 +1371,7 @@ function FontFamilySettingsRow({ ); return ( Date: Tue, 4 Aug 2026 19:02:53 +0200 Subject: [PATCH 36/47] feat(web): say what the Default font resolves to The Default choice now reads as the first family of the default stack that actually renders on this machine - probed, not hardcoded, since the stacks are platform-dependent. The prompt row says it follows the interface font, which is what the composer fallback chain does. Co-Authored-By: Claude Fable 5 --- apps/web/src/appearanceFonts.test.ts | 11 +++++++ apps/web/src/appearanceFonts.ts | 29 +++++++++++++++++ .../components/settings/FontFamilyPicker.tsx | 7 +++-- .../components/settings/SettingsPanels.tsx | 31 ++++++++++++++++++- 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/apps/web/src/appearanceFonts.test.ts b/apps/web/src/appearanceFonts.test.ts index 3f18e65facf4..a7f2af7a9af3 100644 --- a/apps/web/src/appearanceFonts.test.ts +++ b/apps/web/src/appearanceFonts.test.ts @@ -8,6 +8,7 @@ import { DEFAULT_SANS_FONT_STACK, appearanceFontStack, cssFontFamilies, + resolveDefaultFamilyLabel, } from "./appearanceFonts"; describe("cssFontFamilies", () => { @@ -34,6 +35,16 @@ describe("cssFontFamilies", () => { }); }); +describe("resolveDefaultFamilyLabel", () => { + it("names the first renderable family, preferring bundled faces", () => { + expect(resolveDefaultFamilyLabel(DEFAULT_SANS_FONT_STACK)).toBe("DM Sans"); + }); + + it("skips generic keywords and returns null for a stack of only generics", () => { + expect(resolveDefaultFamilyLabel("system-ui, sans-serif")).toBeNull(); + }); +}); + describe("appearanceFontStack", () => { it("prepends the custom family to the default stack", () => { expect(appearanceFontStack("Fira Code", DEFAULT_CODE_FONT_STACK)).toBe( diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 44dd5147f2de..bef14167d032 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -181,6 +181,35 @@ export function isMonospaceFamily(family: string): boolean { } } +/** Bundled webfonts count as present even before document.fonts settles. */ +const BUNDLED_FAMILIES = new Set(["DM Sans Variable", "DM Sans", "JetBrains Mono"]); + +/** + * The first family of a default stack that will actually render - what the + * "Default" choice means on this machine. Generic keywords are skipped: they + * always resolve but name no concrete face. Null when nothing concrete in the + * stack is installed. + */ +export function resolveDefaultFamilyLabel(stack: string): string | null { + for (const raw of stack.split(",")) { + const family = raw.trim().replace(/^(['"])(.*)\1$/, "$2"); + if (family.length === 0) continue; + if ( + /^(system-ui|sans-serif|serif|monospace|ui-monospace|-apple-system|BlinkMacSystemFont)$/i.test( + family, + ) + ) { + continue; + } + if (BUNDLED_FAMILIES.has(family) || isFontFamilyAvailable(family)) { + // The bundled face registers under its variable-font name; the plain + // family name is what users know it as. + return family.replace(/ Variable$/, ""); + } + } + return null; +} + export interface InstalledFontFamiliesResult { readonly families: readonly string[]; /** diff --git a/apps/web/src/components/settings/FontFamilyPicker.tsx b/apps/web/src/components/settings/FontFamilyPicker.tsx index b890ae7acf2e..e025ac30bc97 100644 --- a/apps/web/src/components/settings/FontFamilyPicker.tsx +++ b/apps/web/src/components/settings/FontFamilyPicker.tsx @@ -37,11 +37,14 @@ export function supportsFontEnumeration(): boolean { */ export function FontFamilyPicker({ ariaLabel, + defaultLabel = "Default", selectedFamily, requireMonospace = false, onSelect, }: { ariaLabel: string; + /** What the Default choice reads as, e.g. "Default (SF Mono)". */ + defaultLabel?: string; /** Committed family name; empty string means the built-in default. */ selectedFamily: string; requireMonospace?: boolean; @@ -97,7 +100,7 @@ export function FontFamilyPicker({ className="min-w-0 truncate" style={item === DEFAULT_FONT_VALUE ? undefined : { fontFamily: item }} > - {item === DEFAULT_FONT_VALUE ? "Default" : item} + {item === DEFAULT_FONT_VALUE ? defaultLabel : item} {item === selectedValue ? ( @@ -128,7 +131,7 @@ export function FontFamilyPicker({ className="relative inline-flex min-h-9 w-full min-w-36 cursor-pointer select-none items-center justify-between gap-2 rounded-lg border border-input bg-background px-[calc(--spacing(3)-1px)] text-left text-base text-foreground shadow-xs/5 outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24 sm:min-h-8 sm:text-sm dark:bg-input/32" > - {selectedFamily.length === 0 ? "Default" : selectedFamily} + {selectedFamily.length === 0 ? defaultLabel : selectedFamily} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index ca38965e5f01..009eb5d6b9d2 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -106,7 +106,14 @@ import { } from "../ui/dialog"; import { DraftInput } from "../ui/draft-input"; import { Input } from "../ui/input"; -import { isFontFamilyAvailable, isMonospaceFamily } from "../../appearanceFonts"; +import { + DEFAULT_CODE_FONT_STACK, + DEFAULT_SANS_FONT_STACK, + isFontFamilyAvailable, + isMonospaceFamily, + resolveDefaultFamilyLabel, +} from "../../appearanceFonts"; +import { DEFAULT_TERMINAL_FONT_FAMILY } from "~/terminal/ghostty/surface"; import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; import { FontFamilyPicker, supportsFontEnumeration } from "./FontFamilyPicker"; import { @@ -1154,11 +1161,27 @@ export function AppearanceSettingsPanel() { function FontSettingsGroup() { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); + // Spell out what "Default" resolves to on this machine; the stacks are + // platform-dependent (SF Mono on macOS, the bundled JetBrains Mono + // elsewhere), so the label is probed rather than hardcoded. + const defaultLabels = useMemo(() => { + const sans = resolveDefaultFamilyLabel(DEFAULT_SANS_FONT_STACK); + const code = resolveDefaultFamilyLabel(DEFAULT_CODE_FONT_STACK); + const terminal = resolveDefaultFamilyLabel(DEFAULT_TERMINAL_FONT_FAMILY); + return { + interface: sans === null ? "Default" : `Default (${sans})`, + // The composer inherits whatever the interface preference resolves to. + prompt: "Default (interface font)", + code: code === null ? "Default" : `Default (${code})`, + terminal: terminal === null ? "Default" : `Default (${terminal})`, + }; + }, []); return ( <> updateSettings({ fontFamilySans })} size={{ @@ -1172,6 +1195,7 @@ function FontSettingsGroup() { updateSettings({ fontFamilyComposer })} size={{ @@ -1186,6 +1210,7 @@ function FontSettingsGroup() { updateSettings({ fontFamilyCode })} requireMonospace @@ -1201,6 +1226,7 @@ function FontSettingsGroup() { updateSettings({ fontFamilyTerminal })} requireMonospace @@ -1226,6 +1252,7 @@ function FontFamilySettingsRow({ id, title, description, + defaultLabel, preview, value, onValueChange, @@ -1235,6 +1262,7 @@ function FontFamilySettingsRow({ id?: string; title: string; description: string; + defaultLabel: string; preview?: ReactNode; value: string; onValueChange: (value: string) => void; @@ -1296,6 +1324,7 @@ function FontFamilySettingsRow({ const familyControl = supportsFontEnumeration() ? ( Date: Tue, 4 Aug 2026 19:08:42 +0200 Subject: [PATCH 37/47] feat(web): start font rows as inputs, upgrade to the picker on discovery Every row begins as a plain family-name input whose placeholder names the font actually in use - no Default branding. Focusing the input is the user gesture that runs Local Font Access discovery; when the engine grants it, the control upgrades to the searchable picker, popped open when the swap lands under focus. Blocked or unsupported engines simply keep the input. Co-Authored-By: Claude Fable 5 --- .../components/settings/FontFamilyPicker.tsx | 155 +++++++++++------- .../components/settings/SettingsPanels.tsx | 148 +++++++++-------- 2 files changed, 174 insertions(+), 129 deletions(-) diff --git a/apps/web/src/components/settings/FontFamilyPicker.tsx b/apps/web/src/components/settings/FontFamilyPicker.tsx index e025ac30bc97..6824c9ea9fd2 100644 --- a/apps/web/src/components/settings/FontFamilyPicker.tsx +++ b/apps/web/src/components/settings/FontFamilyPicker.tsx @@ -1,11 +1,7 @@ import { LegendList } from "@legendapp/list/react"; import { CheckIcon, ChevronDownIcon, SearchIcon } from "lucide-react"; -import { useMemo, useState } from "react"; -import { - type InstalledFontFamiliesResult, - isMonospaceFamily, - queryInstalledFontFamilies, -} from "../../appearanceFonts"; +import { useMemo, useState, useSyncExternalStore } from "react"; +import { isMonospaceFamily, queryInstalledFontFamilies } from "../../appearanceFonts"; import { Combobox, ComboboxEmpty, @@ -18,55 +14,96 @@ import { const DEFAULT_FONT_VALUE = "__default__"; -/** - * Whether the engine can enumerate installed fonts (Local Font Access API - - * Chromium and Electron). Rows fall back to a plain family-name input - * elsewhere. - */ -export function supportsFontEnumeration(): boolean { +function supportsFontEnumeration(): boolean { return ( typeof window !== "undefined" && typeof (window as { queryLocalFonts?: unknown }).queryLocalFonts === "function" ); } +type FontEnumerationState = + | { readonly status: "unknown" } + | { readonly status: "granted"; readonly families: readonly string[] } + | { readonly status: "unavailable" }; + +// Shared across every row: once one picker learns the fonts (or learns the +// permission is blocked), the others follow without re-querying — and the +// rows can swap to the plain-input control together. +let enumerationState: FontEnumerationState = supportsFontEnumeration() + ? { status: "unknown" } + : { status: "unavailable" }; +const enumerationListeners = new Set<() => void>(); + +function subscribeToEnumeration(listener: () => void): () => void { + enumerationListeners.add(listener); + return () => enumerationListeners.delete(listener); +} + +function readEnumerationState(): FontEnumerationState { + return enumerationState; +} + +let enumerationLoad: Promise | null = null; + +/** Query installed fonts; call from a user gesture (the permission prompt needs one). */ +export function discoverInstalledFonts(): void { + if (enumerationState.status !== "unknown" || enumerationLoad !== null) return; + enumerationLoad = queryInstalledFontFamilies().then((result) => { + enumerationState = + result.status === "granted" + ? { status: "granted", families: result.families } + : { status: "unavailable" }; + enumerationLoad = null; + for (const listener of enumerationListeners) listener(); + }); +} + +/** + * Whether the engine can list installed fonts (Local Font Access API — + * Chromium and Electron). "unknown" until a row's input is focused and + * discovery resolves the permission; rows render a plain family-name input + * until the state is known granted, then upgrade to the picker. + */ +export function useFontEnumeration(): FontEnumerationState { + return useSyncExternalStore(subscribeToEnumeration, readEnumerationState); +} + /** * A searchable picker over every installed family, the way native editors - * list system fonts. Enumeration happens on open - that click is the user - * gesture the local-fonts permission prompt requires. + * list system fonts. The trigger always names the font in use: the committed + * family, or what the default stack resolves to on this machine. */ export function FontFamilyPicker({ ariaLabel, - defaultLabel = "Default", + defaultFamily, selectedFamily, requireMonospace = false, + initialOpen = false, onSelect, }: { ariaLabel: string; - /** What the Default choice reads as, e.g. "Default (SF Mono)". */ - defaultLabel?: string; - /** Committed family name; empty string means the built-in default. */ + /** What an unset preference renders as, e.g. "DM Sans". */ + defaultFamily: string; + /** Committed family name; empty string means the default is in use. */ selectedFamily: string; requireMonospace?: boolean; + /** Open the popup on mount — set when the control upgrades under focus. */ + initialOpen?: boolean; onSelect: (family: string) => void; }) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(initialOpen); const [query, setQuery] = useState(""); - const [installed, setInstalled] = useState(null); + const enumeration = useFontEnumeration(); const handleOpenChange = (nextOpen: boolean) => { setOpen(nextOpen); - if (!nextOpen) return; - setQuery(""); - if (installed?.status !== "granted") { - void queryInstalledFontFamilies().then(setInstalled); - } + if (nextOpen) setQuery(""); }; const families = useMemo(() => { - if (installed?.status !== "granted") return []; - return requireMonospace ? installed.families.filter(isMonospaceFamily) : installed.families; - }, [installed, requireMonospace]); + if (enumeration.status !== "granted") return []; + return requireMonospace ? enumeration.families.filter(isMonospaceFamily) : enumeration.families; + }, [enumeration, requireMonospace]); const items = useMemo(() => { const trimmedQuery = query.trim().toLowerCase(); @@ -87,34 +124,33 @@ export function FontFamilyPicker({ onSelect(value === DEFAULT_FONT_VALUE ? "" : value); }; - const renderItem = (item: string, index: number) => ( - handlePick(item)} - > -
- - {item === DEFAULT_FONT_VALUE ? defaultLabel : item} - - {item === selectedValue ? ( - - ) : null} -
-
- ); - - const statusNotice = - installed?.status === "denied" - ? "Font access was declined in the browser." - : open && installed === null - ? "Reading installed fonts…" - : null; + const renderItem = (item: string, index: number) => { + const isDefault = item === DEFAULT_FONT_VALUE; + const family = isDefault ? defaultFamily : item; + return ( + handlePick(item)} + > +
+ + {family} + + + {isDefault ? ( + default + ) : null} + {item === selectedValue ? ( + + ) : null} + +
+
+ ); + }; return ( - {selectedFamily.length === 0 ? defaultLabel : selectedFamily} + {selectedFamily.length === 0 ? defaultFamily : selectedFamily} @@ -168,11 +204,6 @@ export function FontFamilyPicker({ />
- {statusNotice ? ( -
- {statusNotice} -
- ) : null}
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 009eb5d6b9d2..a611f72a46d9 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -115,7 +115,7 @@ import { } from "../../appearanceFonts"; import { DEFAULT_TERMINAL_FONT_FAMILY } from "~/terminal/ghostty/surface"; import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; -import { FontFamilyPicker, supportsFontEnumeration } from "./FontFamilyPicker"; +import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker"; import { NumberField, NumberFieldDecrement, @@ -1161,27 +1161,25 @@ export function AppearanceSettingsPanel() { function FontSettingsGroup() { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); - // Spell out what "Default" resolves to on this machine; the stacks are - // platform-dependent (SF Mono on macOS, the bundled JetBrains Mono - // elsewhere), so the label is probed rather than hardcoded. - const defaultLabels = useMemo(() => { - const sans = resolveDefaultFamilyLabel(DEFAULT_SANS_FONT_STACK); - const code = resolveDefaultFamilyLabel(DEFAULT_CODE_FONT_STACK); - const terminal = resolveDefaultFamilyLabel(DEFAULT_TERMINAL_FONT_FAMILY); - return { - interface: sans === null ? "Default" : `Default (${sans})`, - // The composer inherits whatever the interface preference resolves to. - prompt: "Default (interface font)", - code: code === null ? "Default" : `Default (${code})`, - terminal: terminal === null ? "Default" : `Default (${terminal})`, - }; - }, []); + // An unset preference shows the font it resolves to on this machine; the + // stacks are platform-dependent (SF Mono on macOS when installed, the + // bundled JetBrains Mono elsewhere), so the name is probed, not hardcoded. + const defaultFamilies = useMemo( + () => ({ + sans: resolveDefaultFamilyLabel(DEFAULT_SANS_FONT_STACK) ?? "System default", + code: resolveDefaultFamilyLabel(DEFAULT_CODE_FONT_STACK) ?? "System monospace", + terminal: resolveDefaultFamilyLabel(DEFAULT_TERMINAL_FONT_FAMILY) ?? "System monospace", + }), + [], + ); + // The composer inherits whatever the interface preference resolves to. + const interfaceFamily = settings.fontFamilySans.trim() || defaultFamilies.sans; return ( <> updateSettings({ fontFamilySans })} size={{ @@ -1195,7 +1193,7 @@ function FontSettingsGroup() { updateSettings({ fontFamilyComposer })} size={{ @@ -1210,7 +1208,7 @@ function FontSettingsGroup() { updateSettings({ fontFamilyCode })} requireMonospace @@ -1226,7 +1224,7 @@ function FontSettingsGroup() { updateSettings({ fontFamilyTerminal })} requireMonospace @@ -1252,7 +1250,7 @@ function FontFamilySettingsRow({ id, title, description, - defaultLabel, + defaultFamily, preview, value, onValueChange, @@ -1262,7 +1260,8 @@ function FontFamilySettingsRow({ id?: string; title: string; description: string; - defaultLabel: string; + /** What an unset preference renders as, e.g. "DM Sans". */ + defaultFamily: string; preview?: ReactNode; value: string; onValueChange: (value: string) => void; @@ -1321,55 +1320,70 @@ function FontFamilySettingsRow({ onClick={() => onValueChange("")} /> ) : null; - const familyControl = supportsFontEnumeration() ? ( - - ) : ( - { - const next = event.currentTarget.value; - setDraft(next); - setDraftSettled(false); - if (commitTimerRef.current !== null) { - window.clearTimeout(commitTimerRef.current); - } - commitTimerRef.current = window.setTimeout(() => { - commitTimerRef.current = null; - commitDraft(next); - }, 400); - }} - onKeyDown={(event) => { - if (event.key === "Enter") flushDraft(); - if (event.key === "Escape") { - // Discard uncommitted typing without closing the settings page, - // which is what an unhandled Escape does. - event.preventDefault(); - event.stopPropagation(); + const fontEnumeration = useFontEnumeration(); + // Everyone starts on the plain input; focusing it is the user gesture that + // runs font discovery. Where the engine can enumerate, the control then + // upgrades to the picker - popped open when the swap happens under focus, + // so the interaction continues without a second click. + const inputFocusedRef = useRef(false); + const familyControl = + fontEnumeration.status === "granted" ? ( + + ) : ( + { + inputFocusedRef.current = true; + discoverInstalledFonts(); + }} + onBlur={() => { + inputFocusedRef.current = false; + flushDraft(); + }} + onChange={(event) => { + const next = event.currentTarget.value; + setDraft(next); + setDraftSettled(false); if (commitTimerRef.current !== null) { window.clearTimeout(commitTimerRef.current); + } + commitTimerRef.current = window.setTimeout(() => { commitTimerRef.current = null; + commitDraft(next); + }, 400); + }} + onKeyDown={(event) => { + if (event.key === "Enter") flushDraft(); + if (event.key === "Escape") { + // Discard uncommitted typing without closing the settings page, + // which is what an unhandled Escape does. + event.preventDefault(); + event.stopPropagation(); + if (commitTimerRef.current !== null) { + window.clearTimeout(commitTimerRef.current); + commitTimerRef.current = null; + } + setDraft(value); + setDraftSettled(true); } - setDraft(value); - setDraftSettled(true); - } - }} - placeholder="Font family name" - spellCheck={false} - value={draft} - /> - ); + }} + placeholder={defaultFamily} + spellCheck={false} + value={draft} + /> + ); const control = (
{familyControl}
From 6e1d025b3d796fb776322f557671d817457a44e5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 4 Aug 2026 19:15:49 +0200 Subject: [PATCH 38/47] feat(web): font smoothing toggle, system default fonts A macOS-only Typography row toggles native font anti-aliasing (on by default); turning it off applies grayscale -webkit-font-smoothing from the root. The bundled DM Sans and JetBrains Mono webfonts are gone - now that any installed font is selectable, the defaults are the platform's own faces, and the default labels probe accordingly. Co-Authored-By: Claude Fable 5 --- .../settings/DesktopClientSettings.test.ts | 1 + apps/web/index.html | 9 +----- apps/web/package.json | 2 -- apps/web/src/appearanceFonts.test.ts | 5 +--- apps/web/src/appearanceFonts.ts | 24 ++++++++------- .../components/settings/FontFamilyPicker.tsx | 2 +- .../components/settings/SettingsPanels.tsx | 30 +++++++++++++++++-- .../src/components/settings/settingsSearch.ts | 5 ++++ apps/web/src/index.css | 6 ++-- apps/web/src/main.tsx | 3 -- apps/web/src/routes/__root.tsx | 3 ++ apps/web/src/terminal/ghostty/surface.ts | 7 +++-- packages/contracts/src/settings.ts | 4 +++ pnpm-lock.yaml | 16 ---------- 14 files changed, 62 insertions(+), 55 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 9988e500ebe2..53ef74f21911 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -28,6 +28,7 @@ const clientSettings: ClientSettings = { fontSizeInterface: 16, fontSizePrompt: 14, fontSizeTerminal: 12, + fontSmoothing: true, glassOpacity: 80, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, diff --git a/apps/web/index.html b/apps/web/index.html index eccee92878b3..021bcb4156ce 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -47,14 +47,7 @@ body { background: #ffffff; color: #262626; - font-family: - "DM Sans Variable", - "DM Sans", - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - system-ui, - sans-serif; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; } html.dark body { diff --git a/apps/web/package.json b/apps/web/package.json index 53b38b80ba0b..5b1789caee2a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,8 +20,6 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", - "@fontsource-variable/dm-sans": "^5.2.8", - "@fontsource/jetbrains-mono": "^5.2.8", "@formkit/auto-animate": "^0.9.0", "@legendapp/list": "3.2.0", "@lexical/react": "^0.41.0", diff --git a/apps/web/src/appearanceFonts.test.ts b/apps/web/src/appearanceFonts.test.ts index a7f2af7a9af3..8467c13c2cef 100644 --- a/apps/web/src/appearanceFonts.test.ts +++ b/apps/web/src/appearanceFonts.test.ts @@ -36,12 +36,9 @@ describe("cssFontFamilies", () => { }); describe("resolveDefaultFamilyLabel", () => { - it("names the first renderable family, preferring bundled faces", () => { - expect(resolveDefaultFamilyLabel(DEFAULT_SANS_FONT_STACK)).toBe("DM Sans"); - }); - it("skips generic keywords and returns null for a stack of only generics", () => { expect(resolveDefaultFamilyLabel("system-ui, sans-serif")).toBeNull(); + expect(resolveDefaultFamilyLabel("ui-monospace, monospace")).toBeNull(); }); }); diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index bef14167d032..061cd0eae652 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -18,11 +18,10 @@ import { } from "@t3tools/contracts"; export const DEFAULT_SANS_FONT_STACK = - '"DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, ' + - "sans-serif"; + '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif'; export const DEFAULT_CODE_FONT_STACK = - '"SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace'; + 'ui-monospace, "SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace'; function quoteFontFamilyName(name: string): string { const bare = name.trim(); @@ -58,6 +57,8 @@ export interface AppearanceFontPreferences { readonly sizeInterface: number; readonly sizePrompt: number; readonly sizeCode: number; + /** Native macOS anti-aliasing; false forces grayscale `antialiased`. */ + readonly smoothing: boolean; } /** @@ -93,6 +94,14 @@ export function applyAppearanceFontVariables( root.style.setProperty("--font-size-code", `${code}px`); // The @pierre/diffs surfaces read their own hook for code text. root.style.setProperty("--diffs-font-size", `${code}px`); + + // Inherited from the root; only macOS engines honor the property, so no + // platform gate is needed here. + if (preferences.smoothing) { + root.style.removeProperty("-webkit-font-smoothing"); + } else { + root.style.setProperty("-webkit-font-smoothing", "antialiased"); + } } function clampFontSize(value: number, minimum: number, maximum: number, fallback: number): number { @@ -181,9 +190,6 @@ export function isMonospaceFamily(family: string): boolean { } } -/** Bundled webfonts count as present even before document.fonts settles. */ -const BUNDLED_FAMILIES = new Set(["DM Sans Variable", "DM Sans", "JetBrains Mono"]); - /** * The first family of a default stack that will actually render - what the * "Default" choice means on this machine. Generic keywords are skipped: they @@ -201,11 +207,7 @@ export function resolveDefaultFamilyLabel(stack: string): string | null { ) { continue; } - if (BUNDLED_FAMILIES.has(family) || isFontFamilyAvailable(family)) { - // The bundled face registers under its variable-font name; the plain - // family name is what users know it as. - return family.replace(/ Variable$/, ""); - } + if (isFontFamilyAvailable(family)) return family; } return null; } diff --git a/apps/web/src/components/settings/FontFamilyPicker.tsx b/apps/web/src/components/settings/FontFamilyPicker.tsx index 6824c9ea9fd2..43a4ed668fdc 100644 --- a/apps/web/src/components/settings/FontFamilyPicker.tsx +++ b/apps/web/src/components/settings/FontFamilyPicker.tsx @@ -82,7 +82,7 @@ export function FontFamilyPicker({ onSelect, }: { ariaLabel: string; - /** What an unset preference renders as, e.g. "DM Sans". */ + /** What an unset preference renders as, e.g. "Menlo". */ defaultFamily: string; /** Committed family name; empty string means the default is in use. */ selectedFamily: string; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index a611f72a46d9..0a0174d20e21 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -85,6 +85,7 @@ import { sortProviderInstanceEntries, } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; +import { isMacPlatform } from "../../lib/utils"; import { primaryServerObservabilityAtom, primaryServerProvidersAtom, @@ -1162,8 +1163,8 @@ function FontSettingsGroup() { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); // An unset preference shows the font it resolves to on this machine; the - // stacks are platform-dependent (SF Mono on macOS when installed, the - // bundled JetBrains Mono elsewhere), so the name is probed, not hardcoded. + // default stacks are the platform's own faces, so the name is probed, not + // hardcoded. const defaultFamilies = useMemo( () => ({ sans: resolveDefaultFamilyLabel(DEFAULT_SANS_FONT_STACK) ?? "System default", @@ -1242,6 +1243,29 @@ function FontSettingsGroup() { /> } /> + {isMacPlatform(navigator.platform) ? ( + + updateSettings({ fontSmoothing: DEFAULT_UNIFIED_SETTINGS.fontSmoothing }) + } + /> + ) : null + } + control={ + updateSettings({ fontSmoothing: Boolean(checked) })} + aria-label="Use native macOS font anti-aliasing" + /> + } + /> + ) : null} ); } @@ -1260,7 +1284,7 @@ function FontFamilySettingsRow({ id?: string; title: string; description: string; - /** What an unset preference renders as, e.g. "DM Sans". */ + /** What an unset preference renders as, e.g. "Menlo". */ defaultFamily: string; preview?: ReactNode; value: string; diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 87982a350947..1ba231a58350 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -75,6 +75,11 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Terminal font", to: "/settings/appearance", }, + { + id: "font-smoothing", + title: "Font smoothing", + to: "/settings/appearance", + }, { id: "word-wrap", title: "Word wrap", diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 48728a52e8a5..c30453f2eb28 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -134,11 +134,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil the variables and Settings -> Appearance can override them at runtime. The default stacks are mirrored in `appearanceFonts.ts`. */ @theme { - --font-sans: - "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, - sans-serif; + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; --font-mono: - "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace; + ui-monospace, "SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace; } @theme inline { diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 7406f960cd35..c8f6f4de97b9 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -5,9 +5,6 @@ import { passkeys } from "@clerk/electron/passkeys"; import { ClerkProvider as ElectronClerkProvider } from "@clerk/electron/react"; import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; -import "@fontsource-variable/dm-sans/index.css"; -import "@fontsource/jetbrains-mono/400.css"; -import "@fontsource/jetbrains-mono/500.css"; import "./index.css"; import { isElectron } from "./env"; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index b02445b0af9c..bbad8a303c8b 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -161,6 +161,7 @@ function FontAppearanceSync() { const fontSizeInterface = useClientSettings((settings) => settings.fontSizeInterface); const fontSizePrompt = useClientSettings((settings) => settings.fontSizePrompt); const fontSizeCode = useClientSettings((settings) => settings.fontSizeCode); + const fontSmoothing = useClientSettings((settings) => settings.fontSmoothing); useEffect(() => { applyAppearanceFontVariables(document.documentElement, { @@ -170,6 +171,7 @@ function FontAppearanceSync() { sizeInterface: fontSizeInterface, sizePrompt: fontSizePrompt, sizeCode: fontSizeCode, + smoothing: fontSmoothing, }); }, [ fontFamilyCode, @@ -178,6 +180,7 @@ function FontAppearanceSync() { fontSizeCode, fontSizeInterface, fontSizePrompt, + fontSmoothing, ]); return null; diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index f61251591bb2..b460d38d2df7 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -26,10 +26,11 @@ const TERMINAL_GLYPH_FALLBACKS = '"Symbols Nerd Font Mono", "Symbols Nerd Font", "JetBrainsMono Nerd Font", ' + '"JetBrainsMono NF", "FiraCode Nerd Font", "Hack Nerd Font", "MesloLGS NF", ' + '"CaskaydiaCove Nerd Font", "PowerlineSymbols", monospace'; -// SF Mono where the platform has it (macOS), otherwise the bundled JetBrains -// Mono webfont, so the default rendering is identical everywhere else. +// The platform's own monospace faces; concrete names only, because an +// unknown keyword (like ui-monospace) makes canvas font shorthand parsing +// reject the whole string. export const DEFAULT_TERMINAL_FONT_FAMILY = - '"SF Mono", "SFMono-Regular", "JetBrains Mono", ' + TERMINAL_GLYPH_FALLBACKS; + '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", ' + TERMINAL_GLYPH_FALLBACKS; const CONTENT_PADDING = 4; const MIN_SCROLLBAR_THUMB_HEIGHT = 18; /** Half a blink cycle: the visible and hidden phases are equally long. */ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 4199c9ca3bfc..d48b1dcdfd19 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -136,6 +136,9 @@ export const ClientSettingsSchema = Schema.Struct({ fontFamilyComposer: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), fontFamilySans: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), fontFamilyTerminal: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + // Native macOS font anti-aliasing; disabling applies grayscale + // `-webkit-font-smoothing: antialiased`. No effect off macOS. + fontSmoothing: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), // Model favorites. Historically keyed by provider kind, now // widened to `ProviderInstanceId` so users can favorite a specific model // on a custom provider instance (e.g. "Codex Personal · gpt-5") without @@ -748,6 +751,7 @@ export const ClientSettingsPatch = Schema.Struct({ fontFamilyComposer: Schema.optionalKey(FontFamilyPreference), fontFamilySans: Schema.optionalKey(FontFamilyPreference), fontFamilyTerminal: Schema.optionalKey(FontFamilyPreference), + fontSmoothing: Schema.optionalKey(Schema.Boolean), favorites: Schema.optionalKey( Schema.Array( Schema.Struct({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4eee1f6e1a56..f7c6f4cc1f5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -532,12 +532,6 @@ importers: '@effect/atom-react': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(react@19.2.6)(scheduler@0.27.0) - '@fontsource-variable/dm-sans': - specifier: ^5.2.8 - version: 5.2.8 - '@fontsource/jetbrains-mono': - specifier: ^5.2.8 - version: 5.2.8 '@formkit/auto-animate': specifier: ^0.9.0 version: 0.9.0 @@ -2707,12 +2701,6 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} - '@fontsource-variable/dm-sans@5.2.8': - resolution: {integrity: sha512-AxkvMTvNWgfrmlyjiV05vlHYJa+nRQCf1EfvIrQAPBpFJW0O9VTz7oAFr9S3lvbWdmnFoBk7yFqQL86u64nl2g==} - - '@fontsource/jetbrains-mono@5.2.8': - resolution: {integrity: sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==} - '@formkit/auto-animate@0.9.0': resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==} @@ -12791,10 +12779,6 @@ snapshots: '@floating-ui/utils@0.2.11': {} - '@fontsource-variable/dm-sans@5.2.8': {} - - '@fontsource/jetbrains-mono@5.2.8': {} - '@formkit/auto-animate@0.9.0': {} '@hono/node-server@1.19.14(hono@4.12.27)': From c3a46f70b97532452bca48b837b3e957939bdd60 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 4 Aug 2026 19:18:27 +0200 Subject: [PATCH 39/47] feat(web): seed the prompt preview with skill and file-tag pills Co-Authored-By: Claude Fable 5 --- apps/web/src/components/settings/SettingsFontPreviews.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx index 552a379f7c1d..60b58bf4d0ac 100644 --- a/apps/web/src/components/settings/SettingsFontPreviews.tsx +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -19,7 +19,13 @@ import { GhosttyTerminalSurface } from "~/terminal/ghostty/surface"; const EMPTY_TERMINAL_CONTEXTS: ReadonlyArray = []; const EMPTY_SKILLS: ReadonlyArray = []; -const PROMPT_PREVIEW_TEXT = "Fix the flaky test in surface.test.ts and explain the race."; +// Serialized the way the composer stores inline tokens: the $skill and the +// markdown-style file links render as chips, so the preview shows prompt +// text and pills exactly as the real composer draws them. +const PROMPT_PREVIEW_TEXT = + "Use $frontend-design to fix the flaky test in " + + "[surface.test.ts](apps/web/src/terminal/ghostty/surface.test.ts) and align the header with " + + "[SettingsPanels.tsx](apps/web/src/components/settings/SettingsPanels.tsx) before shipping."; function noop() {} From 71b4e7e67842e8a7d24eb838aaa448672fb26214 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 4 Aug 2026 19:19:51 +0200 Subject: [PATCH 40/47] fix(web): un-invert font smoothing to match Cursor and Codex Smoothing on now applies grayscale antialiased rendering (thinner strokes); off restores macOS's heavier default. The description says what actually changes instead of borrowing the ambiguous native anti-aliasing phrasing. Co-Authored-By: Claude Fable 5 --- apps/web/src/appearanceFonts.ts | 10 ++++++---- apps/web/src/components/settings/SettingsPanels.tsx | 4 ++-- packages/contracts/src/settings.ts | 4 ++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 061cd0eae652..0673c9348e29 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -57,7 +57,7 @@ export interface AppearanceFontPreferences { readonly sizeInterface: number; readonly sizePrompt: number; readonly sizeCode: number; - /** Native macOS anti-aliasing; false forces grayscale `antialiased`. */ + /** Grayscale `antialiased` rendering; false keeps the heavier platform default. */ readonly smoothing: boolean; } @@ -96,11 +96,13 @@ export function applyAppearanceFontVariables( root.style.setProperty("--diffs-font-size", `${code}px`); // Inherited from the root; only macOS engines honor the property, so no - // platform gate is needed here. + // platform gate is needed here. Smoothing on means grayscale `antialiased` + // (thinner strokes); off restores the platform default, which macOS renders + // with heavier stem darkening. if (preferences.smoothing) { - root.style.removeProperty("-webkit-font-smoothing"); - } else { root.style.setProperty("-webkit-font-smoothing", "antialiased"); + } else { + root.style.removeProperty("-webkit-font-smoothing"); } } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 0a0174d20e21..eca3f4ea3afc 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1246,7 +1246,7 @@ function FontSettingsGroup() { {isMacPlatform(navigator.platform) ? ( updateSettings({ fontSmoothing: Boolean(checked) })} - aria-label="Use native macOS font anti-aliasing" + aria-label="Font smoothing" /> } /> diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index d48b1dcdfd19..600daf94645a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -136,8 +136,8 @@ export const ClientSettingsSchema = Schema.Struct({ fontFamilyComposer: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), fontFamilySans: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), fontFamilyTerminal: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), - // Native macOS font anti-aliasing; disabling applies grayscale - // `-webkit-font-smoothing: antialiased`. No effect off macOS. + // Grayscale `-webkit-font-smoothing: antialiased` (thinner strokes); + // disabling restores the platform's heavier default. No effect off macOS. fontSmoothing: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), // Model favorites. Historically keyed by provider kind, now // widened to `ProviderInstanceId` so users can favorite a specific model From 1e1e89c5ab871ef429b7db3e2174ce99b05d2618 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 4 Aug 2026 19:28:42 +0200 Subject: [PATCH 41/47] feat(web): name the face a default stack actually renders Generic keywords now resolve to real names: the generic is measured in the DOM against nameable candidates, and an unmatched face on an Apple platform is San Francisco itself (SF Pro / SF Mono). Measurement lives in the DOM, not canvas, whose generic mapping diverges from real rendering - this engine draws ui-monospace as the proportional UI font, which is also why ui-monospace leaves the code stack: concrete mono names first. Co-Authored-By: Claude Fable 5 --- apps/web/src/appearanceFonts.ts | 99 +++++++++++++++++++++++++++++++-- 1 file changed, 95 insertions(+), 4 deletions(-) diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 0673c9348e29..3fb6c821a1b1 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -20,8 +20,10 @@ import { export const DEFAULT_SANS_FONT_STACK = '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif'; +// Concrete names first: some engines alias `ui-monospace` to the +// proportional system UI font, which would break every code surface. export const DEFAULT_CODE_FONT_STACK = - 'ui-monospace, "SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace'; + '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace'; function quoteFontFamilyName(name: string): string { const bare = name.trim(); @@ -192,11 +194,98 @@ export function isMonospaceFamily(family: string): boolean { } } +// Nameable faces the platform generics commonly map to, likeliest first. +// Pixel-comparing a generic against these names the actual face; Apple's own +// UI fonts are deliberately not CSS-nameable, so a miss on an Apple platform +// identifies San Francisco itself. +const SANS_GENERIC_CANDIDATES = [ + "Segoe UI", + "Roboto", + "Noto Sans", + "Ubuntu", + "Cantarell", + "DejaVu Sans", + "Liberation Sans", + "Helvetica Neue", + "Arial", +] as const; +const MONO_GENERIC_CANDIDATES = [ + "Menlo", + "Consolas", + "Cascadia Mono", + "DejaVu Sans Mono", + "Ubuntu Mono", + "Liberation Mono", + "Noto Sans Mono", + "Roboto Mono", + "Monaco", + "Courier New", +] as const; + +const GENERIC_PROBE_TEXT = "RagIl10O@ fjord quiz"; + +/** + * Advance width of the probe text laid out by the DOM - not canvas, whose + * generic-family mapping diverges from real rendering (this engine draws + * `ui-monospace` as the proportional UI font on canvas but not in CSS). + * Identical widths at this size mean the same face for practical purposes. + */ +function measureDomProbeWidth(fontFamily: string): number | null { + try { + const body = document.body; + if (!body) return null; + const span = document.createElement("span"); + span.style.cssText = + "position:absolute;left:-9999px;top:0;visibility:hidden;white-space:pre;font-size:100px;"; + span.style.fontFamily = fontFamily; + span.textContent = GENERIC_PROBE_TEXT; + body.appendChild(span); + const width = span.getBoundingClientRect().width; + span.remove(); + return width > 0 ? width : null; + } catch { + return null; + } +} + +function widthsMatch(left: number, right: number): boolean { + return Math.abs(left - right) < 0.01; +} + +/** + * Name the concrete face a generic keyword renders as, by measuring the + * generic against nameable candidates. Null when the face cannot be + * identified (and the platform gives no definitional answer). + */ +function resolveGenericFamilyLabel(generic: string): string | null { + const lower = generic.toLowerCase(); + if (lower === "serif") return null; + const monoLike = lower === "ui-monospace" || lower === "monospace"; + const genericWidth = measureDomProbeWidth(generic); + if (genericWidth === null) return null; + for (const candidate of monoLike ? MONO_GENERIC_CANDIDATES : SANS_GENERIC_CANDIDATES) { + if (!isFontFamilyAvailable(candidate)) continue; + const candidateWidth = measureDomProbeWidth(`"${candidate}"`); + if (candidateWidth !== null && widthsMatch(genericWidth, candidateWidth)) { + return candidate; + } + } + // No nameable face matched; on Apple platforms that means one of the San + // Francisco faces, which CSS cannot name. Comparing against -apple-system + // tells the UI face apart from SF Mono. + if (/mac|iphone|ipad|ipod/i.test(navigator.platform)) { + const systemWidth = measureDomProbeWidth("-apple-system"); + if (systemWidth !== null && widthsMatch(genericWidth, systemWidth)) return "SF Pro"; + return monoLike ? "SF Mono" : "SF Pro"; + } + return null; +} + /** * The first family of a default stack that will actually render - what the - * "Default" choice means on this machine. Generic keywords are skipped: they - * always resolve but name no concrete face. Null when nothing concrete in the - * stack is installed. + * "Default" choice means on this machine. Concrete names are probed for + * availability; generic keywords are resolved to the face they draw with + * where identifiable. Null when nothing can be named. */ export function resolveDefaultFamilyLabel(stack: string): string | null { for (const raw of stack.split(",")) { @@ -207,6 +296,8 @@ export function resolveDefaultFamilyLabel(stack: string): string | null { family, ) ) { + const resolved = resolveGenericFamilyLabel(family); + if (resolved !== null) return resolved; continue; } if (isFontFamilyAvailable(family)) return family; From c1ef13dc14b5ef78e2650855b2a18c4a5176016d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 4 Aug 2026 19:32:16 +0200 Subject: [PATCH 42/47] fix(web): scale composer pills with the prompt font size Chip metrics move from fixed pixels to em - text, icons, padding, gap, radius, and the dismiss button all track the surrounding font, so the pills match the text at any prompt size. The chat variant pins the original 12px, where every em value resolves to the same pixels as before, and the skill glyph fills its em-sized span instead of a hardcoded 14px box. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/composerInlineChip.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/composerInlineChip.ts b/apps/web/src/components/composerInlineChip.ts index b60b1678943f..f08f9285da94 100644 --- a/apps/web/src/components/composerInlineChip.ts +++ b/apps/web/src/components/composerInlineChip.ts @@ -1,20 +1,23 @@ +// Chip metrics are in em so the pills scale with the text they sit in (the +// composer honors the prompt font-size preference). The chat variant pins the +// original 12px, where every em value resolves to the same pixels as before. const INLINE_CHIP_CLASS_NAME = - "inline-flex max-w-full items-center gap-1 rounded-md border border-border/70 bg-accent/40 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-foreground align-middle"; + "inline-flex max-w-full items-center gap-[0.33em] rounded-[0.5em] border border-border/70 bg-accent/40 px-[0.5em] py-[0.08em] font-medium leading-[1.1] text-foreground align-middle"; -export const CHAT_INLINE_CHIP_CLASS_NAME = INLINE_CHIP_CLASS_NAME; +export const CHAT_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[12px]`; -export const COMPOSER_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} select-none`; +export const COMPOSER_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[0.86em] select-none`; -export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-3.5 shrink-0 opacity-85"; +export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-[1.17em] shrink-0 opacity-85"; export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight"; export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = `${CHAT_INLINE_CHIP_LABEL_CLASS_NAME} select-none`; export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME = - "inline-flex max-w-full select-none items-center gap-1 rounded-md border border-fuchsia-500/25 bg-fuchsia-500/12 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-fuchsia-700 align-middle dark:text-fuchsia-300"; + "inline-flex max-w-full select-none items-center gap-[0.33em] rounded-[0.5em] border border-fuchsia-500/25 bg-fuchsia-500/12 px-[0.5em] py-[0.08em] font-medium text-[0.86em] leading-[1.1] text-fuchsia-700 align-middle dark:text-fuchsia-300"; -export const SKILL_CHIP_ICON_SVG = ``; +export const SKILL_CHIP_ICON_SVG = ``; export const COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME = - "ml-0.5 inline-flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground/72 transition-colors hover:bg-foreground/6 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"; + "ml-[0.17em] inline-flex size-[1.17em] shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground/72 transition-colors hover:bg-foreground/6 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"; From 8e13a97baa6e9c7e33e82fe5c65a83111b837d2a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 4 Aug 2026 19:46:27 +0200 Subject: [PATCH 43/47] feat(web): fold typography overrides behind an Advanced switch The section defaults to two rows - one sans, one monospace - with the demos showing every surface each choice reaches. An Advanced switch in the section header reveals the per-surface rows (prompt, terminal, anti-aliasing); the state persists locally, and a settings-search jump to a hidden row flips the switch for the session so the target exists. The cascade behind the simple view is real: the terminal now inherits the monospace preference unless overridden. Co-Authored-By: Claude Fable 5 --- .../src/components/ThreadTerminalDrawer.tsx | 6 +- .../settings/SettingsPanels.logic.ts | 19 + .../components/settings/SettingsPanels.tsx | 367 ++++++++++++------ .../components/settings/settingsLayout.tsx | 5 + 4 files changed, 278 insertions(+), 119 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index d24a4b3db237..914e04b647dd 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -312,7 +312,11 @@ export function TerminalViewport({ onAddTerminalContext(selection); }); const readTerminalLabel = useEffectEvent(() => terminalLabel); - const terminalFontFamily = useClientSettings((settings) => settings.fontFamilyTerminal); + // The terminal inherits the monospace (code) preference unless it has an + // override of its own, so one font choice drives every mono surface. + const terminalFontFamily = useClientSettings( + (settings) => settings.fontFamilyTerminal.trim() || settings.fontFamilyCode, + ); const terminalFontSize = useClientSettings((settings) => settings.fontSizeTerminal); const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize }); const terminalSession = useAttachedTerminalSession({ diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 1d4baefa53a5..031fdc2a73b9 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -28,6 +28,25 @@ export function projectGroupingModeFromToggle( return lastEnabledMode === "repository_path" ? "repository_path" : "repository"; } +const TYPOGRAPHY_ADVANCED_KEY = "t3code:typography-advanced"; + +/** Whether Settings -> Typography last showed the per-surface override rows. */ +export function readTypographyAdvanced(): boolean { + try { + return localStorage.getItem(TYPOGRAPHY_ADVANCED_KEY) === "true"; + } catch { + return false; + } +} + +export function rememberTypographyAdvanced(advanced: boolean): void { + try { + localStorage.setItem(TYPOGRAPHY_ADVANCED_KEY, String(advanced)); + } catch { + // Storage can be unavailable in restricted browser contexts. + } +} + const LAST_ENABLED_PROJECT_GROUPING_MODE_KEY = "t3code:last-enabled-project-grouping-mode"; export function readLastEnabledProjectGroupingMode(): SidebarProjectGroupingMode { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index eca3f4ea3afc..8953e3d868e6 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -148,6 +148,8 @@ import { readLastEnabledProjectGroupingMode, rememberEnabledProjectGroupingMode, resolveBackgroundActivityProfileOption, + readTypographyAdvanced, + rememberTypographyAdvanced, } from "./SettingsPanels.logic"; import { SettingResetButton, @@ -155,6 +157,7 @@ import { SettingsRow, SettingsSection, useRelativeTimeTick, + useSettingsSearchTargetId, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { ProjectFavicon } from "../ProjectFavicon"; @@ -1129,147 +1132,275 @@ export function AppearanceSettingsPanel() { ) : null} - - - - updateSettings({ - wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, - }) - } - /> - ) : null - } - control={ - updateSettings({ wordWrap: Boolean(checked) })} - aria-label="Wrap code, tables, diffs, and file previews by default" - /> - } - /> - + ); } -function FontSettingsGroup() { +function useFontDefaultFamilies() { const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); // An unset preference shows the font it resolves to on this machine; the // default stacks are the platform's own faces, so the name is probed, not // hardcoded. - const defaultFamilies = useMemo( + const defaults = useMemo( () => ({ sans: resolveDefaultFamilyLabel(DEFAULT_SANS_FONT_STACK) ?? "System default", code: resolveDefaultFamilyLabel(DEFAULT_CODE_FONT_STACK) ?? "System monospace", - terminal: resolveDefaultFamilyLabel(DEFAULT_TERMINAL_FONT_FAMILY) ?? "System monospace", }), [], ); - // The composer inherits whatever the interface preference resolves to. - const interfaceFamily = settings.fontFamilySans.trim() || defaultFamilies.sans; + return { + sans: defaults.sans, + code: defaults.code, + // The composer inherits whatever the interface preference resolves to; + // the terminal inherits the monospace preference the same way. + interfaceFamily: settings.fontFamilySans.trim() || defaults.sans, + monoFamily: settings.fontFamilyCode.trim() || defaults.code, + }; +} + +function InterfaceFontRow({ preview }: { preview?: ReactNode }) { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + const defaults = useFontDefaultFamilies(); + return ( + updateSettings({ fontFamilySans })} + size={{ + label: "Interface font size", + min: MIN_INTERFACE_FONT_SIZE, + max: MAX_INTERFACE_FONT_SIZE, + value: settings.fontSizeInterface, + onChange: (fontSizeInterface) => updateSettings({ fontSizeInterface }), + }} + {...(preview !== undefined ? { preview } : {})} + /> + ); +} + +function PromptFontRow() { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + const defaults = useFontDefaultFamilies(); + return ( + updateSettings({ fontFamilyComposer })} + size={{ + label: "Prompt font size", + min: MIN_PROMPT_FONT_SIZE, + max: MAX_PROMPT_FONT_SIZE, + value: settings.fontSizePrompt, + onChange: (fontSizePrompt) => updateSettings({ fontSizePrompt }), + }} + preview={} + /> + ); +} + +function CodeFontRow({ + title, + description = "Code blocks, diffs, and file previews.", + preview, +}: { + title?: string; + description?: string; + preview?: ReactNode; +}) { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + const defaults = useFontDefaultFamilies(); + return ( + updateSettings({ fontFamilyCode })} + requireMonospace + size={{ + label: "Code font size", + min: MIN_CODE_FONT_SIZE, + max: MAX_CODE_FONT_SIZE, + value: settings.fontSizeCode, + onChange: (fontSizeCode) => updateSettings({ fontSizeCode }), + }} + preview={preview ?? } + /> + ); +} + +function TerminalFontRow() { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + const defaults = useFontDefaultFamilies(); + return ( + updateSettings({ fontFamilyTerminal })} + requireMonospace + size={{ + label: "Terminal font size", + min: MIN_TERMINAL_FONT_SIZE, + max: MAX_TERMINAL_FONT_SIZE, + value: settings.fontSizeTerminal, + onChange: (fontSizeTerminal) => updateSettings({ fontSizeTerminal }), + }} + preview={ + + } + /> + ); +} + +function FontSmoothingRow() { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + if (!isMacPlatform(navigator.platform)) return null; + return ( + + updateSettings({ fontSmoothing: DEFAULT_UNIFIED_SETTINGS.fontSmoothing }) + } + /> + ) : null + } + control={ + updateSettings({ fontSmoothing: Boolean(checked) })} + aria-label="Font smoothing" + /> + } + /> + ); +} + +function WordWrapRow() { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + return ( + updateSettings({ wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap })} + /> + ) : null + } + control={ + updateSettings({ wordWrap: Boolean(checked) })} + aria-label="Wrap code, tables, diffs, and file previews by default" + /> + } + /> + ); +} + +function FontSettingsGroup() { return ( <> - updateSettings({ fontFamilySans })} - size={{ - label: "Interface font size", - min: MIN_INTERFACE_FONT_SIZE, - max: MAX_INTERFACE_FONT_SIZE, - value: settings.fontSizeInterface, - onChange: (fontSizeInterface) => updateSettings({ fontSizeInterface }), - }} - /> - updateSettings({ fontFamilyComposer })} - size={{ - label: "Prompt font size", - min: MIN_PROMPT_FONT_SIZE, - max: MAX_PROMPT_FONT_SIZE, - value: settings.fontSizePrompt, - onChange: (fontSizePrompt) => updateSettings({ fontSizePrompt }), - }} - preview={} - /> - updateSettings({ fontFamilyCode })} - requireMonospace - size={{ - label: "Code font size", - min: MIN_CODE_FONT_SIZE, - max: MAX_CODE_FONT_SIZE, - value: settings.fontSizeCode, - onChange: (fontSizeCode) => updateSettings({ fontSizeCode }), - }} - preview={} - /> - updateSettings({ fontFamilyTerminal })} - requireMonospace - size={{ - label: "Terminal font size", - min: MIN_TERMINAL_FONT_SIZE, - max: MAX_TERMINAL_FONT_SIZE, - value: settings.fontSizeTerminal, - onChange: (fontSizeTerminal) => updateSettings({ fontSizeTerminal }), - }} + + + + + + + ); +} + +/** + * The two-font view: one sans, one monospace. The prompt follows the + * interface font and the terminal follows the monospace font, so the demos + * under each row show every surface the choice reaches. + */ +function SimpleFontRows() { + const settings = usePrimarySettings(); + return ( + <> + } /> + + <> + + + } /> - {isMacPlatform(navigator.platform) ? ( - - updateSettings({ fontSmoothing: DEFAULT_UNIFIED_SETTINGS.fontSmoothing }) - } - /> - ) : null - } - control={ - updateSettings({ fontSmoothing: Boolean(checked) })} - aria-label="Font smoothing" - /> - } - /> - ) : null} ); } +const ADVANCED_TYPOGRAPHY_TARGET_IDS = new Set(["prompt-font", "terminal-font", "font-smoothing"]); + +/** + * The two-font view by default - one sans, one monospace, each cascading to + * every surface it reaches - with an Advanced switch in the section header + * that reveals the per-surface override rows. The choice persists locally, + * and a settings-search jump to an override row flips Advanced on so the + * target exists to scroll to. + */ +function TypographySection() { + const [advanced, setAdvanced] = useState(readTypographyAdvanced); + const searchTargetId = useSettingsSearchTargetId(); + const searchWantsAdvancedRow = + searchTargetId !== null && ADVANCED_TYPOGRAPHY_TARGET_IDS.has(searchTargetId); + useEffect(() => { + if (searchWantsAdvancedRow && !advanced) setAdvanced(true); + }, [searchWantsAdvancedRow, advanced]); + return ( + + Advanced + { + const next = Boolean(checked); + setAdvanced(next); + rememberTypographyAdvanced(next); + }} + aria-label="Show advanced typography settings" + /> + + } + > + {advanced ? : } + + + ); +} + function FontFamilySettingsRow({ id, title, diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index 238fddbc48a4..84fb95a47417 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -62,6 +62,11 @@ function scrollAndFocusSettingsTarget(target: HTMLElement): void { }); } +/** The row id a settings-search jump is currently trying to reach, if any. */ +export function useSettingsSearchTargetId(): string | null { + return useContext(SettingsSearchTargetContext).targetId; +} + function useSettingsSearchTarget(id: string | undefined) { const { targetId, onTargetHandled } = useContext(SettingsSearchTargetContext); const isSearchTarget = id !== undefined && id === targetId; From deb9252868d01c41e6d2d85506941e51c0fc67f4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 4 Aug 2026 23:58:52 +0200 Subject: [PATCH 44/47] fix(review): address font picker and typography review feedback - Keyboard selection commits: the combobox commits through onValueChange instead of per-item clicks, so Enter on a highlighted font works, and keyboard highlights scroll the virtualized list into view the way the branch selector does. - The picker popup opens after mount instead of mounting open, so the exit transition has a style baseline and close cannot wedge. - Preview sessions no longer grant local-fonts: untrusted preview content must not read the user's installed-font fingerprint. The app's own picker runs in the main window session. - The simple-mode Monospace row keeps the code-font search anchor under its custom title. - A settings-search jump to font smoothing on non-macOS no longer pins Advanced on: the target set is platform-gated and each jump expands at most once. - The Advanced preference persists through the shared useLocalStorage hook instead of hand-rolled helpers. Co-Authored-By: Claude Fable 5 --- apps/desktop/src/preview/BrowserSession.ts | 8 ++-- .../components/settings/FontFamilyPicker.tsx | 35 +++++++++++----- .../settings/SettingsPanels.logic.ts | 19 --------- .../components/settings/SettingsPanels.tsx | 40 ++++++++++++------- 4 files changed, 55 insertions(+), 47 deletions(-) diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index d26f9eb634a9..e11d25bbed77 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -23,10 +23,10 @@ const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ "clipboard-sanitized-write", "notifications", "geolocation", - // Local Font Access (queryLocalFonts). Both handlers again: the API - // consults the permission check before ever raising a request, and a - // denied check resolves with an empty font list rather than an error. - "local-fonts", + // Deliberately NOT local-fonts: preview sessions run untrusted web content, + // and silently granting it would hand every page the user's installed-font + // fingerprint (and font file bytes via FontData.blob()). The app's own font + // picker runs in the main window session, which is unaffected by this list. ]); export class BrowserSessionPartitionDerivationError extends Schema.TaggedErrorClass()( diff --git a/apps/web/src/components/settings/FontFamilyPicker.tsx b/apps/web/src/components/settings/FontFamilyPicker.tsx index 43a4ed668fdc..fabbe4a3196f 100644 --- a/apps/web/src/components/settings/FontFamilyPicker.tsx +++ b/apps/web/src/components/settings/FontFamilyPicker.tsx @@ -1,6 +1,6 @@ -import { LegendList } from "@legendapp/list/react"; +import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { CheckIcon, ChevronDownIcon, SearchIcon } from "lucide-react"; -import { useMemo, useState, useSyncExternalStore } from "react"; +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { isMonospaceFamily, queryInstalledFontFamilies } from "../../appearanceFonts"; import { Combobox, @@ -91,8 +91,19 @@ export function FontFamilyPicker({ initialOpen?: boolean; onSelect: (family: string) => void; }) { - const [open, setOpen] = useState(initialOpen); + const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); + // Open after mount rather than mounting open: a popup that first renders in + // its open state never receives Base UI's entrance style baseline, so the + // exit transition on close has no style delta, never fires transitionend, + // and the popup lingers on screen forever. + useEffect(() => { + if (initialOpen) setOpen(true); + // The prop is only meaningful at mount - the control just swapped in + // under an active focus - so later changes are deliberately ignored. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const listRef = useRef(null); const enumeration = useFontEnumeration(); const handleOpenChange = (nextOpen: boolean) => { @@ -128,13 +139,7 @@ export function FontFamilyPicker({ const isDefault = item === DEFAULT_FONT_VALUE; const family = isDefault ? defaultFamily : item; return ( - handlePick(item)} - > +
{family} @@ -161,6 +166,15 @@ export function FontFamilyPicker({ open={open} onOpenChange={handleOpenChange} value={selectedValue} + onValueChange={(next) => { + if (typeof next === "string") handlePick(next); + }} + onItemHighlighted={(_value, eventDetails) => { + // Keyboard highlights must pull the virtualized row into view, or + // arrow keys walk past the rendered window and navigate blind. + if (!open || eventDetails.index < 0 || eventDetails.reason !== "keyboard") return; + void listRef.current?.scrollIndexIntoView?.({ index: eventDetails.index, animated: false }); + }} > + ref={listRef} data={items} keyExtractor={(item) => item} renderItem={({ item, index }) => renderItem(item, index)} diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 031fdc2a73b9..1d4baefa53a5 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -28,25 +28,6 @@ export function projectGroupingModeFromToggle( return lastEnabledMode === "repository_path" ? "repository_path" : "repository"; } -const TYPOGRAPHY_ADVANCED_KEY = "t3code:typography-advanced"; - -/** Whether Settings -> Typography last showed the per-surface override rows. */ -export function readTypographyAdvanced(): boolean { - try { - return localStorage.getItem(TYPOGRAPHY_ADVANCED_KEY) === "true"; - } catch { - return false; - } -} - -export function rememberTypographyAdvanced(advanced: boolean): void { - try { - localStorage.setItem(TYPOGRAPHY_ADVANCED_KEY, String(advanced)); - } catch { - // Storage can be unavailable in restricted browser contexts. - } -} - const LAST_ENABLED_PROJECT_GROUPING_MODE_KEY = "t3code:last-enabled-project-grouping-mode"; export function readLastEnabledProjectGroupingMode(): SidebarProjectGroupingMode { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8953e3d868e6..c08e41267969 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -55,6 +55,7 @@ import * as Arr from "effect/Array"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; import { canCheckForUpdate, @@ -72,6 +73,7 @@ import { import { isElectron } from "../../env"; import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; import { useTheme } from "../../hooks/useTheme"; +import { useLocalStorage } from "../../hooks/useLocalStorage"; import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; @@ -148,8 +150,6 @@ import { readLastEnabledProjectGroupingMode, rememberEnabledProjectGroupingMode, resolveBackgroundActivityProfileOption, - readTypographyAdvanced, - rememberTypographyAdvanced, } from "./SettingsPanels.logic"; import { SettingResetButton, @@ -1219,7 +1219,8 @@ function CodeFontRow({ const defaults = useFontDefaultFamilies(); return ( = new Set([ + "prompt-font", + "terminal-font", + ...(typeof navigator !== "undefined" && isMacPlatform(navigator.platform) + ? ["font-smoothing"] + : []), +]); + +const TYPOGRAPHY_ADVANCED_KEY = "t3code:typography-advanced"; /** * The two-font view by default - one sans, one monospace, each cascading to @@ -1370,13 +1381,18 @@ const ADVANCED_TYPOGRAPHY_TARGET_IDS = new Set(["prompt-font", "terminal-font", * target exists to scroll to. */ function TypographySection() { - const [advanced, setAdvanced] = useState(readTypographyAdvanced); + const [advanced, setAdvanced] = useLocalStorage(TYPOGRAPHY_ADVANCED_KEY, false, Schema.Boolean); const searchTargetId = useSettingsSearchTargetId(); - const searchWantsAdvancedRow = - searchTargetId !== null && ADVANCED_TYPOGRAPHY_TARGET_IDS.has(searchTargetId); + // Flip Advanced on once per search jump so the hidden target can mount and + // scroll; tracking the handled id lets the user turn it back off without + // the still-set target immediately re-expanding the section. + const lastExpandedTargetRef = useRef(null); useEffect(() => { - if (searchWantsAdvancedRow && !advanced) setAdvanced(true); - }, [searchWantsAdvancedRow, advanced]); + if (searchTargetId === null || !ADVANCED_TYPOGRAPHY_TARGET_IDS.has(searchTargetId)) return; + if (lastExpandedTargetRef.current === searchTargetId) return; + lastExpandedTargetRef.current = searchTargetId; + setAdvanced(true); + }, [searchTargetId, setAdvanced]); return ( { - const next = Boolean(checked); - setAdvanced(next); - rememberTypographyAdvanced(next); - }} + onCheckedChange={(checked) => setAdvanced(Boolean(checked))} aria-label="Show advanced typography settings" /> From aeef720fb733a94b2ad0faec66a4aec6518879cc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 5 Aug 2026 00:27:27 +0200 Subject: [PATCH 45/47] feat(web): show the font picker immediately when permission is granted The input-until-focused flow exists only because raising the browser's local-fonts prompt needs a user gesture. When the permission is already granted - Electron approves silently with no prompt UI, and a browser that granted once reports granted on return visits - no gesture is needed, so the rows probe the Permissions API at mount and render the picker right away. "prompt" and "denied" keep the focus-driven flow. Co-Authored-By: Claude Fable 5 --- .../components/settings/FontFamilyPicker.tsx | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/FontFamilyPicker.tsx b/apps/web/src/components/settings/FontFamilyPicker.tsx index fabbe4a3196f..aaa5d8c605a8 100644 --- a/apps/web/src/components/settings/FontFamilyPicker.tsx +++ b/apps/web/src/components/settings/FontFamilyPicker.tsx @@ -58,13 +58,41 @@ export function discoverInstalledFonts(): void { }); } +let grantedProbeStarted = false; + +/** + * Discover eagerly when the permission is already granted, so the picker + * renders without waiting for a focus. Electron's default permission handler + * approves silently (it has no prompt UI), and a browser that granted once + * reports "granted" on later visits — in both, no user gesture is needed. + * "prompt" and "denied" states change nothing: the focus-driven flow stays, + * because raising the browser prompt still requires a gesture. + */ +function probeAlreadyGrantedPermission(): void { + if (grantedProbeStarted || enumerationState.status !== "unknown") return; + grantedProbeStarted = true; + const permissions = typeof navigator !== "undefined" ? navigator.permissions : undefined; + if (typeof permissions?.query !== "function") return; + permissions.query({ name: "local-fonts" as PermissionName }).then( + (status) => { + if (status.state === "granted") discoverInstalledFonts(); + }, + () => { + // The engine does not recognize the permission name; keep the + // focus-driven flow. + }, + ); +} + /** * Whether the engine can list installed fonts (Local Font Access API — - * Chromium and Electron). "unknown" until a row's input is focused and - * discovery resolves the permission; rows render a plain family-name input - * until the state is known granted, then upgrade to the picker. + * Chromium and Electron). "unknown" until discovery resolves the permission; + * rows render a plain family-name input until the state is known granted, + * then upgrade to the picker. Where the permission is already granted, + * discovery starts at mount and the picker appears without a focus. */ export function useFontEnumeration(): FontEnumerationState { + useEffect(probeAlreadyGrantedPermission, []); return useSyncExternalStore(subscribeToEnumeration, readEnumerationState); } From 67599b2710c3ca75350b51b6313793566dc65cf5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 5 Aug 2026 00:40:09 +0200 Subject: [PATCH 46/47] fix(web): parse the diff preview patch per mount The parse cache hands back shared mutable file objects, and a FileDiff instance keys its render and highlight bookkeeping on them - the same reason DiffPanel scopes its cache per theme. With the simple and advanced typography views both consuming one cached parse, a remounted preview could inherit stale already-highlighted state and render plain text. Each mount now parses under its own scope. Co-Authored-By: Claude Fable 5 --- .../src/components/settings/SettingsFontPreviews.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx index 60b58bf4d0ac..ebe707268a3f 100644 --- a/apps/web/src/components/settings/SettingsFontPreviews.tsx +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -1,5 +1,5 @@ import { FileDiff } from "@pierre/diffs/react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "../ComposerPromptEditor"; import { terminalThemeFromApp } from "../ThreadTerminalDrawer"; import { useTheme } from "../../hooks/useTheme"; @@ -76,9 +76,15 @@ const DIFF_PREVIEW_PATCH = [ */ export function CodeFontPreview() { const { resolvedTheme } = useTheme(); + // Parse per mount: the parse cache returns shared mutable file objects, and + // a FileDiff instance keys its render/highlight bookkeeping on them. Two + // mounts of this preview (the simple and advanced typography views) handing + // the same objects to different FileDiff instances can leave a remounted + // diff convinced it is already highlighted, rendering plain text forever. + const instanceId = useId(); const renderablePatch = useMemo( - () => getRenderablePatch(DIFF_PREVIEW_PATCH, "settings-font-preview"), - [], + () => getRenderablePatch(DIFF_PREVIEW_PATCH, `settings-font-preview:${instanceId}`), + [instanceId], ); if (renderablePatch?.kind !== "files") return null; return ( From 284ea9f7d0cee3c74697ebe40acb2b67936902b7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 5 Aug 2026 00:49:11 +0200 Subject: [PATCH 47/47] fix(web): render the diff preview statically The interactive FileDiff's mount lifecycle can race the shared shiki singleton when the typography views remount it - its render cache is seeded highlighted-before-highlighting, so a lost race locks in an unhighlighted frame permanently. The preview needs no interactivity, so it now renders once per theme through the SSR pipeline (which always awaits the highlighter before emitting HTML) and injects the finished markup into a shadow root, the same way FileDiff hosts it. Co-Authored-By: Claude Fable 5 --- .../settings/SettingsFontPreviews.tsx | 83 +++++++++++-------- 1 file changed, 49 insertions(+), 34 deletions(-) diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx index ebe707268a3f..7190ec69313e 100644 --- a/apps/web/src/components/settings/SettingsFontPreviews.tsx +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -1,13 +1,9 @@ -import { FileDiff } from "@pierre/diffs/react"; -import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; +import { preloadPatchFile } from "@pierre/diffs/ssr"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "../ComposerPromptEditor"; import { terminalThemeFromApp } from "../ThreadTerminalDrawer"; import { useTheme } from "../../hooks/useTheme"; -import { - getRenderablePatch, - resolveDiffThemeName, - resolveFileDiffPath, -} from "../../lib/diffRendering"; +import { resolveDiffThemeName, type DiffThemeName } from "../../lib/diffRendering"; import { GhosttyTerminalSurface } from "~/terminal/ghostty/surface"; // The font previews are the real surfaces, not lookalikes: the composer's @@ -69,37 +65,56 @@ const DIFF_PREVIEW_PATCH = [ "", ].join("\n"); -/** - * The diff panel's file diff, rendered by its real pipeline. The one-off - * worker pool is not worth it for a three-line patch, so the diff renders on - * the main thread. - */ +// Rendered once per theme through the SSR pipeline, which always awaits the +// shared highlighter before producing HTML. The interactive FileDiff's mount +// lifecycle can race that highlighter when the typography views remount it +// (toggling Advanced) and lock in an unhighlighted frame; a static preview +// needs none of that lifecycle, so it uses the deterministic renderer and +// injects the finished HTML into a shadow root, exactly as FileDiff would. +const diffPreviewHtmlByTheme = new Map>(); + +function loadDiffPreviewHtml(theme: DiffThemeName): Promise { + let promise = diffPreviewHtmlByTheme.get(theme); + if (promise === undefined) { + promise = preloadPatchFile({ + patch: DIFF_PREVIEW_PATCH, + options: { diffStyle: "unified", theme }, + }).then((results) => results.map((result) => result.prerenderedHTML)); + diffPreviewHtmlByTheme.set(theme, promise); + } + return promise; +} + +function StaticDiffHtml({ html }: { html: string }) { + const hostRef = useRef(null); + useEffect(() => { + const host = hostRef.current; + if (host === null) return; + const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" }); + shadow.innerHTML = html; + }, [html]); + return
; +} + +/** The diff panel's file diff, statically rendered by its real pipeline. */ export function CodeFontPreview() { const { resolvedTheme } = useTheme(); - // Parse per mount: the parse cache returns shared mutable file objects, and - // a FileDiff instance keys its render/highlight bookkeeping on them. Two - // mounts of this preview (the simple and advanced typography views) handing - // the same objects to different FileDiff instances can leave a remounted - // diff convinced it is already highlighted, rendering plain text forever. - const instanceId = useId(); - const renderablePatch = useMemo( - () => getRenderablePatch(DIFF_PREVIEW_PATCH, `settings-font-preview:${instanceId}`), - [instanceId], - ); - if (renderablePatch?.kind !== "files") return null; + const themeName = resolveDiffThemeName(resolvedTheme); + const [htmlByFile, setHtmlByFile] = useState(null); + useEffect(() => { + let cancelled = false; + void loadDiffPreviewHtml(themeName).then((html) => { + if (!cancelled) setHtmlByFile(html); + }); + return () => { + cancelled = true; + }; + }, [themeName]); + if (htmlByFile === null) return null; return (
- {renderablePatch.files.map((fileDiff) => ( - + {htmlByFile.map((html, index) => ( + ))}
);