diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8d76ea83a33..9988e500ebe 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -20,6 +20,14 @@ const clientSettings: ClientSettings = { diffIgnoreWhitespace: true, environmentIdentificationMode: "artwork", favorites: [], + fontFamilyCode: "", + 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 new file mode 100644 index 00000000000..616ab7bd86a --- /dev/null +++ b/apps/web/src/appearanceFonts.test.ts @@ -0,0 +1,82 @@ +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, + appearanceFontStack, + cssFontFamilies, + fontOptionCategories, +} 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"'); + }); + + 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", () => { + 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); + }); +}); + +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 new file mode 100644 index 00000000000..86939a1316c --- /dev/null +++ b/apps/web/src/appearanceFonts.ts @@ -0,0 +1,255 @@ +/** + * 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"; + +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; + readonly sizeInterface: number; + readonly sizePrompt: number; + readonly sizeCode: number; +} + +/** + * Apply the preferences to the root element. Unset families remove the + * override so the stylesheet defaults (and theme changes) stay in charge. + * + * 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 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", preferences.composer, "var(--font-sans)"], + ]; + for (const [variable, custom, fallback] of families) { + const list = cssFontFamilies(custom); + if (list === null) { + root.style.removeProperty(variable); + } else { + root.style.setProperty(variable, `${list}, ${fallback}`); + } + } + + 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`); +} + +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))); +} + +export function clampInterfaceFontSize(value: number): number { + return clampFontSize( + value, + MIN_INTERFACE_FONT_SIZE, + MAX_INTERFACE_FONT_SIZE, + DEFAULT_INTERFACE_FONT_SIZE, + ); +} + +export function clampPromptFontSize(value: number): number { + return clampFontSize(value, MIN_PROMPT_FONT_SIZE, MAX_PROMPT_FONT_SIZE, DEFAULT_PROMPT_FONT_SIZE); +} + +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; + +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; + if (/^(system-ui|sans-serif|serif|monospace|ui-monospace)$/i.test(families)) return true; + try { + 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; + } +} + +/** + * 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"]); + +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/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 3a83f5c9a0f..701609a155d 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 } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; @@ -214,6 +214,98 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ ); }); +/** + * 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. + */ +const COMPACT_EXPAND_HYSTERESIS_PX = 16; + +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 = 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; + } + 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]")) { + // 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 keeps reporting + // the full width it would need when expanded. + needed += textWidth; + } else { + // 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); + }, []); + + // 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); + document.fonts.addEventListener("loadingdone", measure); + return () => { + observer.disconnect(); + document.fonts.removeEventListener("loadingdone", measure); + }; + }, [element, measure]); + + return overflows; +} + export const BranchToolbar = memo(function BranchToolbar({ environmentId, threadId, @@ -300,11 +392,17 @@ export const BranchToolbar = memo(function BranchToolbar({ canPickEnvironment: showEnvironmentPicker, }); const isMobile = useIsMobile(); + const [stripElement, setStripElement] = useState(null); + const labelsOverflow = useLabelsOverflow(stripElement); 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 d300139d3cf..ca778daad31 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,12 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe ) : ( )} - + + + diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index e4ed54758ff..2cf99547752 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -49,7 +49,12 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir ) : ( )} - {activeEnvironment?.label ?? "Run on"} + + {activeEnvironment?.label ?? "Run on"} + ); } @@ -72,7 +77,12 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir ) : ( )} - + + + diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 169126788ae..f64bdedaa59 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1747,12 +1747,14 @@ function ComposerPromptEditorInner({ return ( -
+
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/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index d58d2b37cd1..82cfa9abac1 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/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index dd7da738626..c0307ceff0d 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -57,6 +57,7 @@ import { type ThreadTerminalGroup, } from "../types"; import { readLocalApi } from "~/localApi"; +import { useClientSettings } from "../hooks/useSettings"; import { useAttachedTerminalSession } from "../state/terminalSessions"; import { serverEnvironment } from "../state/server"; import { previewEnvironment } from "../state/preview"; @@ -131,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)"; @@ -305,6 +312,9 @@ export function TerminalViewport({ onAddTerminalContext(selection); }); const readTerminalLabel = useEffectEvent(() => terminalLabel); + const terminalFontFamily = useClientSettings((settings) => settings.fontFamilyTerminal); + const terminalFontSize = useClientSettings((settings) => settings.fontSizeTerminal); + const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize }); const terminalSession = useAttachedTerminalSession({ environmentId, terminal: { @@ -367,6 +377,13 @@ export function TerminalViewport({ keybindingsRef.current = keybindings; }, [keybindings]); + useEffect(() => { + 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; if (!mount) return; @@ -378,8 +395,10 @@ export function TerminalViewport({ let setupCleanups: Array<() => void> = []; const setup = async (): Promise<(() => void) | null> => { + const setupFont = terminalFontRef.current; const terminalOptions: GhosttyTerminalSurfaceOptions = { theme: terminalThemeFromApp(mount), + font: terminalFontOptions(setupFont.family, setupFont.size), onData: (data) => handleData(data), onResize: (cols, rows) => void resizeTerminal(cols, rows), onSelectionChange: () => handleSelectionChange(), @@ -397,6 +416,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. + 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; 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 5385751924e..63c8b5e71bd 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2,14 +2,15 @@ import { ArchiveIcon, ArchiveX, InfoIcon, + ListIcon, LoaderIcon, PlusIcon, RefreshCwIcon, SettingsIcon, } from "lucide-react"; import { Link } from "@tanstack/react-router"; -import type { CSSProperties } from "react"; -import { useCallback, useMemo, useRef, useState } from "react"; +import type { CSSProperties, ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { defaultInstanceIdForDriver, @@ -34,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, @@ -97,6 +106,19 @@ 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, + MONO_FONT_OPTIONS, + SANS_FONT_OPTIONS, + appearanceFontStack, + availableFontOptions, + fontOptionCategories, + isFontFamilyAvailable, + isMonospaceFamily, + type FontOption, +} from "../../appearanceFonts"; import { NumberField, NumberFieldDecrement, @@ -104,7 +126,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"; @@ -585,6 +615,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 + ? ["Prompt 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"] : []), @@ -628,6 +668,14 @@ export function useSettingsRestore(onRestored?: () => void) { settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, settings.environmentIdentificationMode, + settings.fontFamilyCode, + settings.fontFamilyComposer, + settings.fontFamilySans, + settings.fontFamilyTerminal, + settings.fontSizeCode, + settings.fontSizeInterface, + settings.fontSizePrompt, + settings.fontSizeTerminal, settings.glassOpacity, settings.enableAssistantStreaming, settings.enableProviderUpdateChecks, @@ -671,6 +719,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]); @@ -951,14 +1003,37 @@ 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 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; 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 ( @@ -1020,7 +1095,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. +

+
+ } + /> + 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 +

+
+
+ } + /> + 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={ + +
+                
+                  1
+                  {"  "}
+                  function{" "}
+                  formatUser
+                  (user) {"{"}
+                  {"\n"}
+                  2
+                  {"    "}
+                  return{" "}
+                  {"`${user.name} <${user.email}>`"}{" "}
+                  {"// 0O 1lI"}
+                  {"\n"}
+                  3
+                  {"  "}
+                  {"}"}
+                
+              
+
+ } + /> + 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={ + +
+                
+                  ${" "}
+                  npm run dev
+                  {"\n"}
+                  {"\u2713"} Ready in 430ms
+                  {"\n"}
+                  Local:{" "}
+                  http://localhost:3000
+                
+              
+
+ } + /> +
); } +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; + return ( +
+ { + const next = Number(event.currentTarget.value); + if (Number.isInteger(next) && next >= min && next <= max) { + draftRef.current = next; + setDraft(next); + } + }} + step={1} + style={style} + type="range" + value={shown} + /> + + {shown} px + +
+ ); +} + +/** 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, + options, + preview, + value, + onValueChange, + requireMonospace = false, + size, +}: { + title: string; + description: string; + options: readonly FontOption[]; + 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(); + 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 + // 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, 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 acceptsFamily = (candidate: string) => + isFontFamilyAvailable(candidate) && (!requireMonospace || isMonospaceFamily(candidate)); + const commitDraft = (next: string) => { + setDraftSettled(true); + // 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); + } + }; + const flushDraft = () => { + if (commitTimerRef.current === null) return; + window.clearTimeout(commitTimerRef.current); + 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); + const draftTrimmed = customDraft.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; + 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 + + + ) : ( + + )} +
+ +
+ } + > + {preview} +
+ ); +} + export function GeneralSettingsPanel() { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 296944e113a..ca0416dba5b 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); @@ -638,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; @@ -650,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; @@ -689,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); @@ -701,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; } } @@ -956,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; } @@ -1014,8 +1015,27 @@ body { pre, code { - font-family: - "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace; + font-family: var(--font-mono); +} + +/* 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); +} + +/* @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) */ @@ -1049,6 +1069,24 @@ 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)); + font-size: var(--font-size-prompt, 0.875rem); +} + +/* 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); + } +} + .t3-ghostty-canvas { cursor: text; } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 346991d114d..b02445b0af9 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,35 @@ function GlassAppearanceSync() { return null; } +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(() => { + applyAppearanceFontVariables(document.documentElement, { + sans: fontFamilySans, + code: fontFamilyCode, + composer: fontFamilyComposer, + sizeInterface: fontSizeInterface, + sizePrompt: fontSizePrompt, + sizeCode: fontSizeCode, + }); + }, [ + fontFamilyCode, + fontFamilyComposer, + fontFamilySans, + fontSizeCode, + fontSizeInterface, + fontSizePrompt, + ]); + + return null; +} + function DocumentTitleSync() { const primaryServerVersion = useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 1cb9502649b..5cb6574338f 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, @@ -232,6 +233,41 @@ 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); + expect(terminalFontFamily("Cascadia Code, Menlo").startsWith('"Cascadia Code", Menlo, ')).toBe( + true, + ); + 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 86c1ad5329c..fd98a5287eb 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; @@ -59,13 +60,69 @@ 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; + // 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}`; } +/** + * 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))); @@ -334,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; @@ -406,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(); @@ -521,6 +580,7 @@ export class GhosttyTerminalSurface { } if (this.disposed || epoch !== this.fontEpoch) return; this.fontFamily = fontFamily; + this.requestedFontSize = fontSize; this.fontSize = fontSize; this.applyFontMetrics(); } @@ -557,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)); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7edda2e52e5..4199c9ca3bf 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -58,10 +58,54 @@ 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"; +/** + * 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 +120,22 @@ 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(""))), + 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 +740,14 @@ 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), + fontFamilyTerminal: Schema.optionalKey(FontFamilyPreference), favorites: Schema.optionalKey( Schema.Array( Schema.Struct({