diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index aa0b0743e933..e11d25bbed77 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", + // 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/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8d76ea83a33e..53ef74f21911 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -20,6 +20,15 @@ const clientSettings: ClientSettings = { diffIgnoreWhitespace: true, environmentIdentificationMode: "artwork", favorites: [], + fontFamilyCode: "", + fontFamilyComposer: "", + fontFamilySans: "", + fontFamilyTerminal: "", + fontSizeCode: 13, + 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 new file mode 100644 index 000000000000..8467c13c2cef --- /dev/null +++ b/apps/web/src/appearanceFonts.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + clampCodeFontSize, + clampInterfaceFontSize, + clampPromptFontSize, + DEFAULT_CODE_FONT_STACK, + DEFAULT_SANS_FONT_STACK, + appearanceFontStack, + cssFontFamilies, + resolveDefaultFamilyLabel, +} 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("resolveDefaultFamilyLabel", () => { + 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(); + }); +}); + +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 000000000000..3fb6c821a1b1 --- /dev/null +++ b/apps/web/src/appearanceFonts.ts @@ -0,0 +1,352 @@ +/** + * 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 = + '-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 = + '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", 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; + /** Grayscale `antialiased` rendering; false keeps the heavier platform default. */ + readonly smoothing: boolean; +} + +/** + * 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`); + + // Inherited from the root; only macOS engines honor the property, so no + // 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.setProperty("-webkit-font-smoothing", "antialiased"); + } else { + root.style.removeProperty("-webkit-font-smoothing"); + } +} + +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); +} + +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; + } +} + +// 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. 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(",")) { + 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, + ) + ) { + const resolved = resolveGenericFamilyLabel(family); + if (resolved !== null) return resolved; + continue; + } + if (isFontFamilyAvailable(family)) return family; + } + return null; +} + +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"; +} + +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/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index f7d1856da039..a3f043c65368 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 d300139d3cf5..ca778daad31c 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 e4ed54758ff4..2cf99547752a 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 169126788ae8..f64bdedaa59c 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 9e86e5fb6b44..0fd513d36a74 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 5c7f6a774ee4..914e04b647dd 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -56,6 +56,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,7 +132,13 @@ function normalizeComputedColor(value: string | null | undefined, fallback: stri return value ?? fallback; } -function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { +/** 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 }; +} + +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)"; @@ -305,6 +312,13 @@ export function TerminalViewport({ onAddTerminalContext(selection); }); const readTerminalLabel = useEffectEvent(() => terminalLabel); + // 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({ environmentId, terminal: { @@ -367,6 +381,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 +399,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 +420,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/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"; diff --git a/apps/web/src/components/settings/FontFamilyPicker.tsx b/apps/web/src/components/settings/FontFamilyPicker.tsx new file mode 100644 index 000000000000..aaa5d8c605a8 --- /dev/null +++ b/apps/web/src/components/settings/FontFamilyPicker.tsx @@ -0,0 +1,254 @@ +import { LegendList, type LegendListRef } from "@legendapp/list/react"; +import { CheckIcon, ChevronDownIcon, SearchIcon } from "lucide-react"; +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { isMonospaceFamily, queryInstalledFontFamilies } from "../../appearanceFonts"; +import { + Combobox, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxListVirtualized, + ComboboxPopup, + ComboboxTrigger, +} from "../ui/combobox"; + +const DEFAULT_FONT_VALUE = "__default__"; + +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(); + }); +} + +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 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); +} + +/** + * A searchable picker over every installed family, the way native editors + * 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, + defaultFamily, + selectedFamily, + requireMonospace = false, + initialOpen = false, + onSelect, +}: { + ariaLabel: string; + /** What an unset preference renders as, e.g. "Menlo". */ + 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 [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) => { + setOpen(nextOpen); + if (nextOpen) setQuery(""); + }; + + const families = useMemo(() => { + if (enumeration.status !== "granted") return []; + return requireMonospace ? enumeration.families.filter(isMonospaceFamily) : enumeration.families; + }, [enumeration, 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) => { + const isDefault = item === DEFAULT_FONT_VALUE; + const family = isDefault ? defaultFamily : item; + return ( + +
+ + {family} + + + {isDefault ? ( + default + ) : null} + {item === selectedValue ? ( + + ) : null} + +
+
+ ); + }; + + return ( + { + 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 }); + }} + > + + + {selectedFamily.length === 0 ? defaultFamily : selectedFamily} + + + + +
+
+
+
+
+ No fonts found. +
+ + + ref={listRef} + data={items} + keyExtractor={(item) => item} + renderItem={({ item, index }) => renderItem(item, index)} + estimatedItemSize={30} + drawDistance={360} + style={{ height: Math.min(items.length * 30, 288) }} + /> + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx new file mode 100644 index 000000000000..7190ec69313e --- /dev/null +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -0,0 +1,232 @@ +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 { resolveDiffThemeName, type DiffThemeName } 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 = []; + +// 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() {} + +/** 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"); + +// 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(); + 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 ( +
+ {htmlByFile.map((html, index) => ( + + ))} +
+ ); +} + +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 31ac4bba66ee..c08e41267969 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -8,8 +8,8 @@ import { 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 +34,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, @@ -47,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, @@ -64,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"; @@ -77,6 +87,7 @@ import { sortProviderInstanceEntries, } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; +import { isMacPlatform } from "../../lib/utils"; import { primaryServerObservabilityAtom, primaryServerProvidersAtom, @@ -97,6 +108,17 @@ 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, + isFontFamilyAvailable, + isMonospaceFamily, + resolveDefaultFamilyLabel, +} from "../../appearanceFonts"; +import { DEFAULT_TERMINAL_FONT_FAMILY } from "~/terminal/ghostty/surface"; +import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; +import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker"; import { NumberField, NumberFieldDecrement, @@ -135,6 +157,7 @@ import { SettingsRow, SettingsSection, useRelativeTimeTick, + useSettingsSearchTargetId, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { ProjectFavicon } from "../ProjectFavicon"; @@ -586,6 +609,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"] : []), @@ -629,6 +662,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, @@ -672,6 +713,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]); @@ -958,8 +1003,8 @@ export function AppearanceSettingsPanel() { 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 ( @@ -1021,7 +1066,7 @@ 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 useFontDefaultFamilies() { + const settings = usePrimarySettings(); + // 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 defaults = useMemo( + () => ({ + sans: resolveDefaultFamilyLabel(DEFAULT_SANS_FONT_STACK) ?? "System default", + code: resolveDefaultFamilyLabel(DEFAULT_CODE_FONT_STACK) ?? "System monospace", + }), + [], + ); + 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 ( + <> + + + + + + + ); +} + +/** + * 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 ( + <> + } /> + + + + + } + /> + + ); +} + +// Font smoothing only renders on macOS, so a search jump to it elsewhere +// must not flip the section - the target would never mount to be scrolled to. +const ADVANCED_TYPOGRAPHY_TARGET_IDS: ReadonlySet = 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 + * 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] = useLocalStorage(TYPOGRAPHY_ADVANCED_KEY, false, Schema.Boolean); + const searchTargetId = useSettingsSearchTargetId(); + // 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 (searchTargetId === null || !ADVANCED_TYPOGRAPHY_TARGET_IDS.has(searchTargetId)) return; + if (lastExpandedTargetRef.current === searchTargetId) return; + lastExpandedTargetRef.current = searchTargetId; + setAdvanced(true); + }, [searchTargetId, setAdvanced]); + return ( + + Advanced + setAdvanced(Boolean(checked))} + aria-label="Show advanced typography settings" + /> + + } + > + {advanced ? : } + + + ); +} + +function FontFamilySettingsRow({ + id, + title, + description, + defaultFamily, + preview, + value, + onValueChange, + requireMonospace = false, + size, +}: { + id?: string; + title: string; + description: string; + /** What an unset preference renders as, e.g. "Menlo". */ + defaultFamily: string; + 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(); + // 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 [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, 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; + } + setDraft(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(draft); + }; + 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 && draftTrimmed.length > 0 && draftTrimmed !== trimmed; + const resetAction = + trimmed.length > 0 ? ( + onValueChange("")} + /> + ) : null; + 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); + } + }} + placeholder={defaultFamily} + spellCheck={false} + value={draft} + /> + ); + const control = ( +
+
{familyControl}
+ +
+ ); + return ( + + {preview} + ); } 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; diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 4ead6eff4d79..1ba231a58350 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -55,6 +55,31 @@ export const SETTINGS_SEARCH_ITEMS = [ // The setting is stage-dependent, so its parent section is the stable destination. targetId: "appearance", }, + { + id: "interface-font", + title: "Interface font", + to: "/settings/appearance", + }, + { + id: "prompt-font", + title: "Prompt font", + to: "/settings/appearance", + }, + { + id: "code-font", + title: "Code font", + to: "/settings/appearance", + }, + { + id: "terminal-font", + 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 9d7b4a41ee42..c30453f2eb28 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -130,6 +130,15 @@ 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: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --font-mono: + ui-monospace, "SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace; +} + @theme inline { --color-zinc-25: oklch(99.2% 0 0); --animate-skeleton: skeleton 2s -1s infinite linear; @@ -138,11 +147,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); @@ -672,11 +676,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; @@ -684,32 +688,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; @@ -723,7 +727,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); @@ -735,54 +739,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; } } @@ -990,14 +994,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; } @@ -1048,8 +1047,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) */ @@ -1083,6 +1101,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/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 346991d114de..bbad8a303c8b 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,38 @@ 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); + const fontSmoothing = useClientSettings((settings) => settings.fontSmoothing); + + useEffect(() => { + applyAppearanceFontVariables(document.documentElement, { + sans: fontFamilySans, + code: fontFamilyCode, + composer: fontFamilyComposer, + sizeInterface: fontSizeInterface, + sizePrompt: fontSizePrompt, + sizeCode: fontSizeCode, + smoothing: fontSmoothing, + }); + }, [ + fontFamilyCode, + fontFamilyComposer, + fontFamilySans, + fontSizeCode, + fontSizeInterface, + fontSizePrompt, + fontSmoothing, + ]); + + 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 9c1fde901680..7f94a4c95c8d 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -19,6 +19,7 @@ import { terminalLinkAtPosition, terminalContentOriginY, terminalFontFamily, + fittedTerminalFontSize, terminalFontSize, terminalWheelArrowData, terminalWheelDeltaRows, @@ -256,6 +257,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 9b6a1f875503..b460d38d2df7 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; @@ -25,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. */ @@ -61,13 +63,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))); @@ -350,6 +408,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; @@ -425,6 +484,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(); @@ -544,6 +604,7 @@ export class GhosttyTerminalSurface { } if (this.disposed || epoch !== this.fontEpoch) return; this.fontFamily = fontFamily; + this.requestedFontSize = fontSize; this.fontSize = fontSize; this.applyFontMetrics(); } @@ -588,6 +649,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 7edda2e52e5c..600daf94645a 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,25 @@ 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(""))), + // 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 // on a custom provider instance (e.g. "Codex Personal · gpt-5") without @@ -680,6 +743,15 @@ 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), + 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)':