authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-08-05 00:55:06+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-05 00:55:06+02:00
log8eca20005b47e197b3610f7996f3fd02355c1891
tree4e7d3739950cb761867a93d3f5c879edd92ff793
parent2fa1fec8d8f63bc8fa4579e7c0fd280b21de02ef
signature Signed by PGP key B5690EEEBB952194

feat(web): configurable fonts and sizes under Settings → Appearance (#5103)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

26 files changed, 1934 insertions(+), 122 deletions(-)

apps/desktop/src/preview/BrowserSession.ts+4
...@@ -23,6 +23,10 @@ const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet<string> = new Set([...@@ -23,6 +23,10 @@ const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet<string> = new Set([
23 "clipboard-sanitized-write",23 "clipboard-sanitized-write",
24 "notifications",24 "notifications",
25 "geolocation",25 "geolocation",
26 // Deliberately NOT local-fonts: preview sessions run untrusted web content,
27 // and silently granting it would hand every page the user's installed-font
28 // fingerprint (and font file bytes via FontData.blob()). The app's own font
29 // picker runs in the main window session, which is unaffected by this list.
26]);30]);
2731
28export class BrowserSessionPartitionDerivationError extends Schema.TaggedErrorClass<BrowserSessionPartitionDerivationError>()(32export class BrowserSessionPartitionDerivationError extends Schema.TaggedErrorClass<BrowserSessionPartitionDerivationError>()(
apps/desktop/src/settings/DesktopClientSettings.test.ts+9
...@@ -20,6 +20,15 @@ const clientSettings: ClientSettings = {...@@ -20,6 +20,15 @@ const clientSettings: ClientSettings = {
20 diffIgnoreWhitespace: true,20 diffIgnoreWhitespace: true,
21 environmentIdentificationMode: "artwork",21 environmentIdentificationMode: "artwork",
22 favorites: [],22 favorites: [],
23 fontFamilyCode: "",
24 fontFamilyComposer: "",
25 fontFamilySans: "",
26 fontFamilyTerminal: "",
27 fontSizeCode: 13,
28 fontSizeInterface: 16,
29 fontSizePrompt: 14,
30 fontSizeTerminal: 12,
31 fontSmoothing: true,
23 glassOpacity: 80,32 glassOpacity: 80,
24 providerModelPreferences: {},33 providerModelPreferences: {},
25 sidebarAutoSettleAfterDays: 3,34 sidebarAutoSettleAfterDays: 3,
apps/web/index.html+1-8
...@@ -47,14 +47,7 @@...@@ -47,14 +47,7 @@
47 body {47 body {
48 background: #ffffff;48 background: #ffffff;
49 color: #262626;49 color: #262626;
50 font-family:50 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
51 "DM Sans Variable",
52 "DM Sans",
53 -apple-system,
54 BlinkMacSystemFont,
55 "Segoe UI",
56 system-ui,
57 sans-serif;
58 }51 }
5952
60 html.dark body {53 html.dark body {
apps/web/package.json-2
...@@ -20,8 +20,6 @@...@@ -20,8 +20,6 @@
20 "@dnd-kit/sortable": "^10.0.0",20 "@dnd-kit/sortable": "^10.0.0",
21 "@dnd-kit/utilities": "^3.2.2",21 "@dnd-kit/utilities": "^3.2.2",
22 "@effect/atom-react": "catalog:",22 "@effect/atom-react": "catalog:",
23 "@fontsource-variable/dm-sans": "^5.2.8",
24 "@fontsource/jetbrains-mono": "^5.2.8",
25 "@formkit/auto-animate": "^0.9.0",23 "@formkit/auto-animate": "^0.9.0",
26 "@legendapp/list": "3.2.0",24 "@legendapp/list": "3.2.0",
27 "@lexical/react": "^0.41.0",25 "@lexical/react": "^0.41.0",
apps/web/src/appearanceFonts.test.ts created+71
...@@ -0,0 +1,71 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 clampCodeFontSize,
5 clampInterfaceFontSize,
6 clampPromptFontSize,
7 DEFAULT_CODE_FONT_STACK,
8 DEFAULT_SANS_FONT_STACK,
9 appearanceFontStack,
10 cssFontFamilies,
11 resolveDefaultFamilyLabel,
12} from "./appearanceFonts";
13
14describe("cssFontFamilies", () => {
15 it("returns null for effectively empty input", () => {
16 expect(cssFontFamilies("")).toBeNull();
17 expect(cssFontFamilies(" ")).toBeNull();
18 expect(cssFontFamilies(" , , ")).toBeNull();
19 });
20
21 it("quotes names with spaces and keeps single idents bare", () => {
22 expect(cssFontFamilies("Fira Code")).toBe('"Fira Code"');
23 expect(cssFontFamilies("monospace")).toBe("monospace");
24 expect(cssFontFamilies('"Comic Mono"')).toBe('"Comic Mono"');
25 });
26
27 it("normalizes comma-separated lists and strips embedded quotes", () => {
28 expect(cssFontFamilies(" Fira Code , Menlo ")).toBe('"Fira Code", Menlo');
29 expect(cssFontFamilies('Bad"Name')).toBe('"BadName"');
30 });
31
32 it("quotes names that are not single CSS idents", () => {
33 expect(cssFontFamilies("3270 Nerd Font")).toBe('"3270 Nerd Font"');
34 expect(cssFontFamilies("M+ 1m")).toBe('"M+ 1m"');
35 });
36});
37
38describe("resolveDefaultFamilyLabel", () => {
39 it("skips generic keywords and returns null for a stack of only generics", () => {
40 expect(resolveDefaultFamilyLabel("system-ui, sans-serif")).toBeNull();
41 expect(resolveDefaultFamilyLabel("ui-monospace, monospace")).toBeNull();
42 });
43});
44
45describe("appearanceFontStack", () => {
46 it("prepends the custom family to the default stack", () => {
47 expect(appearanceFontStack("Fira Code", DEFAULT_CODE_FONT_STACK)).toBe(
48 `"Fira Code", ${DEFAULT_CODE_FONT_STACK}`,
49 );
50 });
51
52 it("falls back to the default stack when unset", () => {
53 expect(appearanceFontStack("", DEFAULT_SANS_FONT_STACK)).toBe(DEFAULT_SANS_FONT_STACK);
54 });
55});
56
57describe("font size clamping", () => {
58 it("keeps sizes inside the ranges the UI can absorb", () => {
59 expect(clampInterfaceFontSize(16)).toBe(16);
60 expect(clampInterfaceFontSize(2)).toBe(12);
61 expect(clampInterfaceFontSize(96)).toBe(20);
62 expect(clampPromptFontSize(40)).toBe(20);
63 expect(clampCodeFontSize(1)).toBe(10);
64 });
65
66 it("rounds fractional values and falls back for unusable input", () => {
67 expect(clampCodeFontSize(13.4)).toBe(13);
68 expect(clampInterfaceFontSize(Number.NaN)).toBe(16);
69 expect(clampPromptFontSize(Number.POSITIVE_INFINITY)).toBe(14);
70 });
71});
apps/web/src/appearanceFonts.ts created+352
...@@ -0,0 +1,352 @@
1/**
2 * Font preferences from Settings → Appearance, applied as CSS custom
3 * properties. The default stacks mirror the `--font-sans` / `--font-mono`
4 * definitions in `index.css`; a custom family is always prepended to the
5 * matching default stack so glyph coverage never regresses.
6 */
7
8import {
9 DEFAULT_CODE_FONT_SIZE,
10 DEFAULT_INTERFACE_FONT_SIZE,
11 DEFAULT_PROMPT_FONT_SIZE,
12 MAX_CODE_FONT_SIZE,
13 MAX_INTERFACE_FONT_SIZE,
14 MAX_PROMPT_FONT_SIZE,
15 MIN_CODE_FONT_SIZE,
16 MIN_INTERFACE_FONT_SIZE,
17 MIN_PROMPT_FONT_SIZE,
18} from "@t3tools/contracts";
19
20export const DEFAULT_SANS_FONT_STACK =
21 '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif';
22
23// Concrete names first: some engines alias `ui-monospace` to the
24// proportional system UI font, which would break every code surface.
25export const DEFAULT_CODE_FONT_STACK =
26 '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace';
27
28function quoteFontFamilyName(name: string): string {
29 const bare = name.trim();
30 if (bare.length === 0) return "";
31 // Already quoted, or a single ident that needs no quoting.
32 if (/^(['"]).*\1$/.test(bare)) return bare;
33 if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(bare)) return bare;
34 return `"${bare.replaceAll('"', "")}"`;
35}
36
37/**
38 * Normalize a user-entered family (single name or comma-separated list) into a
39 * safe CSS font-family list, or null when the input is effectively empty.
40 */
41export function cssFontFamilies(input: string): string | null {
42 const families = input
43 .split(",")
44 .map(quoteFontFamilyName)
45 .filter((name) => name.length > 0);
46 return families.length > 0 ? families.join(", ") : null;
47}
48
49/** The full stack a preference resolves to: custom families before the default. */
50export function appearanceFontStack(custom: string, defaultStack: string): string {
51 const families = cssFontFamilies(custom);
52 return families === null ? defaultStack : `${families}, ${defaultStack}`;
53}
54
55export interface AppearanceFontPreferences {
56 readonly sans: string;
57 readonly code: string;
58 readonly composer: string;
59 readonly sizeInterface: number;
60 readonly sizePrompt: number;
61 readonly sizeCode: number;
62 /** Grayscale `antialiased` rendering; false keeps the heavier platform default. */
63 readonly smoothing: boolean;
64}
65
66/**
67 * Apply the preferences to the root element. Unset families remove the
68 * override so the stylesheet defaults (and theme changes) stay in charge.
69 *
70 * Sizes are always written: the interface size drives the root font size (and
71 * with it every rem-based dimension), while the prompt and code sizes stay in
72 * absolute pixels so they do not scale twice.
73 */
74export function applyAppearanceFontVariables(
75 root: HTMLElement,
76 preferences: AppearanceFontPreferences,
77): void {
78 const families: ReadonlyArray<readonly [variable: string, custom: string, fallback: string]> = [
79 ["--font-sans", preferences.sans, DEFAULT_SANS_FONT_STACK],
80 ["--font-mono", preferences.code, DEFAULT_CODE_FONT_STACK],
81 // The composer falls back to whatever the sans preference resolves to.
82 ["--font-composer", preferences.composer, "var(--font-sans)"],
83 ];
84 for (const [variable, custom, fallback] of families) {
85 const list = cssFontFamilies(custom);
86 if (list === null) {
87 root.style.removeProperty(variable);
88 } else {
89 root.style.setProperty(variable, `${list}, ${fallback}`);
90 }
91 }
92
93 root.style.fontSize = `${clampInterfaceFontSize(preferences.sizeInterface)}px`;
94 root.style.setProperty("--font-size-prompt", `${clampPromptFontSize(preferences.sizePrompt)}px`);
95 const code = clampCodeFontSize(preferences.sizeCode);
96 root.style.setProperty("--font-size-code", `${code}px`);
97 // The @pierre/diffs surfaces read their own hook for code text.
98 root.style.setProperty("--diffs-font-size", `${code}px`);
99
100 // Inherited from the root; only macOS engines honor the property, so no
101 // platform gate is needed here. Smoothing on means grayscale `antialiased`
102 // (thinner strokes); off restores the platform default, which macOS renders
103 // with heavier stem darkening.
104 if (preferences.smoothing) {
105 root.style.setProperty("-webkit-font-smoothing", "antialiased");
106 } else {
107 root.style.removeProperty("-webkit-font-smoothing");
108 }
109}
110
111function clampFontSize(value: number, minimum: number, maximum: number, fallback: number): number {
112 if (!Number.isFinite(value)) return fallback;
113 return Math.min(maximum, Math.max(minimum, Math.round(value)));
114}
115
116export function clampInterfaceFontSize(value: number): number {
117 return clampFontSize(
118 value,
119 MIN_INTERFACE_FONT_SIZE,
120 MAX_INTERFACE_FONT_SIZE,
121 DEFAULT_INTERFACE_FONT_SIZE,
122 );
123}
124
125export function clampPromptFontSize(value: number): number {
126 return clampFontSize(value, MIN_PROMPT_FONT_SIZE, MAX_PROMPT_FONT_SIZE, DEFAULT_PROMPT_FONT_SIZE);
127}
128
129export function clampCodeFontSize(value: number): number {
130 return clampFontSize(value, MIN_CODE_FONT_SIZE, MAX_CODE_FONT_SIZE, DEFAULT_CODE_FONT_SIZE);
131}
132
133const FONT_PROBE_TEXT = "mmmmmmmmMMWli1O0@# fjord";
134let fontProbeContext: CanvasRenderingContext2D | null | undefined;
135
136function probeWidth(fontList: string): number | null {
137 if (fontProbeContext === undefined) {
138 fontProbeContext = document.createElement("canvas").getContext("2d");
139 }
140 if (fontProbeContext === null) return null;
141 fontProbeContext.font = `16px ${fontList}`;
142 return fontProbeContext.measureText(FONT_PROBE_TEXT).width;
143}
144
145/**
146 * Canvas metric probing instead of document.fonts.check(): check() reports
147 * true for families that are not installed at all (nothing needs loading), so
148 * it cannot filter the dropdown. A family exists when falling back to at
149 * least one generic changes the measured advance.
150 */
151export function isFontFamilyAvailable(family: string): boolean {
152 const families = cssFontFamilies(family);
153 if (families === null) return false;
154 if (/^(system-ui|sans-serif|serif|monospace|ui-monospace)$/i.test(families)) return true;
155 try {
156 for (const generic of ["monospace", "serif", "sans-serif"]) {
157 const baseline = probeWidth(generic);
158 const candidate = probeWidth(`${families}, ${generic}`);
159 if (baseline === null || candidate === null) return false;
160 if (candidate !== baseline) return true;
161 }
162 return false;
163 } catch {
164 return false;
165 }
166}
167
168/**
169 * Whether a family renders every character on the same advance. Cell-grid
170 * surfaces (the terminal) require this: a proportional face draws its text
171 * narrower than the lattice the cursor and selection are placed on, which
172 * reads as ragged gaps and a cursor stranded to the right of the text.
173 *
174 * Unmeasurable environments answer true, so a missing canvas never blocks a
175 * legitimate font.
176 */
177export function isMonospaceFamily(family: string): boolean {
178 const families = cssFontFamilies(family);
179 if (families === null) return true;
180 try {
181 if (fontProbeContext === undefined) {
182 fontProbeContext = document.createElement("canvas").getContext("2d");
183 }
184 if (fontProbeContext === null) return true;
185 // Fall back to a generic mono so an absent face measures as monospace and
186 // is left for the normal fallback chain to resolve.
187 fontProbeContext.font = `32px ${families}, monospace`;
188 const narrow = fontProbeContext.measureText("i").width;
189 const wide = fontProbeContext.measureText("M").width;
190 if (!Number.isFinite(narrow) || !Number.isFinite(wide) || wide === 0) return true;
191 return Math.abs(wide - narrow) < 0.5;
192 } catch {
193 return true;
194 }
195}
196
197// Nameable faces the platform generics commonly map to, likeliest first.
198// Pixel-comparing a generic against these names the actual face; Apple's own
199// UI fonts are deliberately not CSS-nameable, so a miss on an Apple platform
200// identifies San Francisco itself.
201const SANS_GENERIC_CANDIDATES = [
202 "Segoe UI",
203 "Roboto",
204 "Noto Sans",
205 "Ubuntu",
206 "Cantarell",
207 "DejaVu Sans",
208 "Liberation Sans",
209 "Helvetica Neue",
210 "Arial",
211] as const;
212const MONO_GENERIC_CANDIDATES = [
213 "Menlo",
214 "Consolas",
215 "Cascadia Mono",
216 "DejaVu Sans Mono",
217 "Ubuntu Mono",
218 "Liberation Mono",
219 "Noto Sans Mono",
220 "Roboto Mono",
221 "Monaco",
222 "Courier New",
223] as const;
224
225const GENERIC_PROBE_TEXT = "RagIl10O@ fjord quiz";
226
227/**
228 * Advance width of the probe text laid out by the DOM - not canvas, whose
229 * generic-family mapping diverges from real rendering (this engine draws
230 * `ui-monospace` as the proportional UI font on canvas but not in CSS).
231 * Identical widths at this size mean the same face for practical purposes.
232 */
233function measureDomProbeWidth(fontFamily: string): number | null {
234 try {
235 const body = document.body;
236 if (!body) return null;
237 const span = document.createElement("span");
238 span.style.cssText =
239 "position:absolute;left:-9999px;top:0;visibility:hidden;white-space:pre;font-size:100px;";
240 span.style.fontFamily = fontFamily;
241 span.textContent = GENERIC_PROBE_TEXT;
242 body.appendChild(span);
243 const width = span.getBoundingClientRect().width;
244 span.remove();
245 return width > 0 ? width : null;
246 } catch {
247 return null;
248 }
249}
250
251function widthsMatch(left: number, right: number): boolean {
252 return Math.abs(left - right) < 0.01;
253}
254
255/**
256 * Name the concrete face a generic keyword renders as, by measuring the
257 * generic against nameable candidates. Null when the face cannot be
258 * identified (and the platform gives no definitional answer).
259 */
260function resolveGenericFamilyLabel(generic: string): string | null {
261 const lower = generic.toLowerCase();
262 if (lower === "serif") return null;
263 const monoLike = lower === "ui-monospace" || lower === "monospace";
264 const genericWidth = measureDomProbeWidth(generic);
265 if (genericWidth === null) return null;
266 for (const candidate of monoLike ? MONO_GENERIC_CANDIDATES : SANS_GENERIC_CANDIDATES) {
267 if (!isFontFamilyAvailable(candidate)) continue;
268 const candidateWidth = measureDomProbeWidth(`"${candidate}"`);
269 if (candidateWidth !== null && widthsMatch(genericWidth, candidateWidth)) {
270 return candidate;
271 }
272 }
273 // No nameable face matched; on Apple platforms that means one of the San
274 // Francisco faces, which CSS cannot name. Comparing against -apple-system
275 // tells the UI face apart from SF Mono.
276 if (/mac|iphone|ipad|ipod/i.test(navigator.platform)) {
277 const systemWidth = measureDomProbeWidth("-apple-system");
278 if (systemWidth !== null && widthsMatch(genericWidth, systemWidth)) return "SF Pro";
279 return monoLike ? "SF Mono" : "SF Pro";
280 }
281 return null;
282}
283
284/**
285 * The first family of a default stack that will actually render - what the
286 * "Default" choice means on this machine. Concrete names are probed for
287 * availability; generic keywords are resolved to the face they draw with
288 * where identifiable. Null when nothing can be named.
289 */
290export function resolveDefaultFamilyLabel(stack: string): string | null {
291 for (const raw of stack.split(",")) {
292 const family = raw.trim().replace(/^(['"])(.*)\1$/, "$2");
293 if (family.length === 0) continue;
294 if (
295 /^(system-ui|sans-serif|serif|monospace|ui-monospace|-apple-system|BlinkMacSystemFont)$/i.test(
296 family,
297 )
298 ) {
299 const resolved = resolveGenericFamilyLabel(family);
300 if (resolved !== null) return resolved;
301 continue;
302 }
303 if (isFontFamilyAvailable(family)) return family;
304 }
305 return null;
306}
307
308export interface InstalledFontFamiliesResult {
309 readonly families: readonly string[];
310 /**
311 * "unsupported" - the engine has no Local Font Access API (Safari,
312 * Firefox); "denied" - the API exists but the user declined the permission
313 * prompt. Both fall back to the curated catalog.
314 */
315 readonly status: "granted" | "denied" | "unsupported";
316}
317
318let installedFamiliesCache: InstalledFontFamiliesResult | null = null;
319
320/**
321 * Every installed family via the Local Font Access API (Chromium and
322 * Electron). Call from a user gesture: the first call raises the browser's
323 * local-fonts permission prompt. A denial is not cached, so reopening the
324 * picker can ask again after the user changes the site setting.
325 */
326export async function queryInstalledFontFamilies(): Promise<InstalledFontFamiliesResult> {
327 if (installedFamiliesCache !== null) return installedFamiliesCache;
328 const query = (
329 window as Window & {
330 queryLocalFonts?: () => Promise<ReadonlyArray<{ readonly family: string }>>;
331 }
332 ).queryLocalFonts;
333 if (typeof query !== "function") {
334 installedFamiliesCache = { families: [], status: "unsupported" };
335 return installedFamiliesCache;
336 }
337 try {
338 const fonts = await query.call(window);
339 const families = [...new Set(fonts.map((font) => font.family))]
340 // Dot-prefixed families are macOS-internal UI faces; selecting one is
341 // never intended and most refuse to render for web content anyway.
342 .filter((family) => !family.startsWith("."))
343 .sort((left, right) => left.localeCompare(right));
344 // A denied permission check resolves with an empty list instead of
345 // throwing; no machine has zero fonts, so treat empty as denied.
346 if (families.length === 0) return { families: [], status: "denied" };
347 installedFamiliesCache = { families, status: "granted" };
348 return installedFamiliesCache;
349 } catch {
350 return { families: [], status: "denied" };
351 }
352}
apps/web/src/components/BranchToolbar.tsx+100-2
...@@ -9,7 +9,7 @@ import {...@@ -9,7 +9,7 @@ import {
9 HistoryIcon,9 HistoryIcon,
10 MonitorIcon,10 MonitorIcon,
11} from "lucide-react";11} from "lucide-react";
12import { memo, useCallback, useMemo } from "react";12import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
1313
14import { useComposerDraftStore, type DraftId } from "../composerDraftStore";14import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
15import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities";15import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities";
...@@ -214,6 +214,98 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({...@@ -214,6 +214,98 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
214 );214 );
215});215});
216216
217/**
218 * Collapse the strip's labels to icons only when the text no longer fits.
219 *
220 * Hidden labels stay measurable (they collapse to invisible absolute boxes,
221 * which keep their natural width), so the required width can be recomputed in
222 * either state on every pass - no remembered widths that could go stale or
223 * latch the strip compact. A small hysteresis keeps the boundary from
224 * flapping between states.
225 */
226const COMPACT_EXPAND_HYSTERESIS_PX = 16;
227
228function useLabelsOverflow(element: HTMLDivElement | null): boolean {
229 const [overflows, setOverflows] = useState(false);
230 // A render-synced mirror instead of useEffectEvent: the compiler memoizes
231 // the event callback, which left observers reading the first render's null
232 // element forever.
233 const stateRef = useRef({ element, overflows });
234 stateRef.current = { element, overflows };
235
236 const measure = useCallback(() => {
237 const { element: current, overflows: compact } = stateRef.current;
238 if (!current) return;
239 const available = current.clientWidth;
240 if (available === 0) return;
241 // flex-1 stretches the groups to fill the strip, so their own boxes always
242 // measure "full". Sum the laid-out content instead, skipping hidden form
243 // artifacts and absolutely-positioned nodes (the compact-hidden labels).
244 const contentWidth = (parent: Element): number => {
245 const gap = Number.parseFloat(getComputedStyle(parent).columnGap) || 0;
246 let width = 0;
247 let counted = 0;
248 for (const child of parent.children) {
249 if (!(child instanceof HTMLElement)) continue;
250 if (child.offsetWidth <= 1) continue;
251 const position = getComputedStyle(child).position;
252 if (position === "absolute" || position === "fixed") continue;
253 width += child.offsetWidth;
254 counted += 1;
255 }
256 return width + gap * Math.max(0, counted - 1);
257 };
258 const stripGap = Number.parseFloat(getComputedStyle(current).columnGap) || 0;
259 let needed = 0;
260 let groups = 0;
261 for (const child of current.children) {
262 if (!(child instanceof HTMLElement) || child.offsetWidth <= 1) continue;
263 needed += contentWidth(child);
264 groups += 1;
265 }
266 needed += stripGap * Math.max(0, groups - 1);
267 for (const label of current.querySelectorAll<HTMLElement>("[data-composer-label]")) {
268 // The clipping can happen below the marker (SelectValue truncates
269 // internally), where the outer span's scrollWidth matches its clipped
270 // box. The text's real width is the largest scrollWidth in the subtree.
271 let textWidth = label.scrollWidth;
272 for (const inner of label.querySelectorAll<HTMLElement>("*")) {
273 textWidth = Math.max(textWidth, inner.scrollWidth);
274 }
275 if (compact) {
276 // Compact: the label is squeezed to zero width but keeps reporting
277 // the full width it would need when expanded.
278 needed += textWidth;
279 } else {
280 // Expanded: the label is in flow; only the clipped remainder is
281 // missing from the content sum.
282 needed += Math.max(0, textWidth - label.clientWidth);
283 }
284 }
285 setOverflows(compact ? needed > available - COMPACT_EXPAND_HYSTERESIS_PX : needed > available);
286 }, []);
287
288 // Label widths can change without the strip box moving (font family or
289 // size preferences), so re-measure on every render as well as on resize
290 // and font loads.
291 useEffect(() => {
292 measure();
293 });
294
295 useEffect(() => {
296 if (!element) return;
297 const observer = new ResizeObserver(measure);
298 observer.observe(element);
299 document.fonts.addEventListener("loadingdone", measure);
300 return () => {
301 observer.disconnect();
302 document.fonts.removeEventListener("loadingdone", measure);
303 };
304 }, [element, measure]);
305
306 return overflows;
307}
308
217export const BranchToolbar = memo(function BranchToolbar({309export const BranchToolbar = memo(function BranchToolbar({
218 environmentId,310 environmentId,
219 threadId,311 threadId,
...@@ -300,11 +392,17 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -300,11 +392,17 @@ export const BranchToolbar = memo(function BranchToolbar({
300 canPickEnvironment: showEnvironmentPicker,392 canPickEnvironment: showEnvironmentPicker,
301 });393 });
302 const isMobile = useIsMobile();394 const isMobile = useIsMobile();
395 const [stripElement, setStripElement] = useState<HTMLDivElement | null>(null);
396 const labelsOverflow = useLabelsOverflow(stripElement);
303397
304 if (!hasActiveThread || !activeProject) return null;398 if (!hasActiveThread || !activeProject) return null;
305399
306 return (400 return (
307 <div className="chat-composer-context-strip -mt-4 mx-auto flex w-[calc(100%-2.75rem)] max-w-[calc(48rem-2.75rem)] items-center gap-2 ps-1 pe-2 pt-5 pb-1">401 <div
402 ref={setStripElement}
403 data-compact={labelsOverflow ? "" : undefined}
404 className="chat-composer-context-strip group/composer-context -mt-4 mx-auto flex w-[calc(100%-2.75rem)] max-w-[calc(48rem-2.75rem)] items-center gap-2 ps-1 pe-2 pt-5 pb-1"
405 >
308 {isMobile ? (406 {isMobile ? (
309 <MobileRunContextSelector407 <MobileRunContextSelector
310 envLocked={envLocked}408 envLocked={envLocked}
apps/web/src/components/BranchToolbarBranchSelector.tsx+6-1
...@@ -749,7 +749,12 @@ export function BranchToolbarBranchSelector({...@@ -749,7 +749,12 @@ export function BranchToolbarBranchSelector({
749 disabled={isInitialBranchesLoadPending || isBranchActionPending}749 disabled={isInitialBranchesLoadPending || isBranchActionPending}
750 >750 >
751 <GitBranchIcon className="size-3 shrink-0 opacity-70" />751 <GitBranchIcon className="size-3 shrink-0 opacity-70" />
752 <span className="min-w-0 max-w-[240px] truncate">{triggerLabel}</span>752 <span
753 data-composer-label
754 className="min-w-0 max-w-[240px] truncate transition-[max-width,opacity] duration-300 ease-out group-data-[compact]/composer-context:max-w-0 group-data-[compact]/composer-context:opacity-0"
755 >
756 {triggerLabel}
757 </span>
753 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />758 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />
754 </ComboboxTrigger>759 </ComboboxTrigger>
755 </span>760 </span>
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+7-2
...@@ -82,7 +82,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe...@@ -82,7 +82,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
82 <SelectTrigger82 <SelectTrigger
83 variant="ghost"83 variant="ghost"
84 size="xs"84 size="xs"
85 className="shrink-0 font-medium"85 className="min-w-0 shrink font-medium"
86 aria-label="Workspace"86 aria-label="Workspace"
87 >87 >
88 {effectiveEnvMode === "worktree" ? (88 {effectiveEnvMode === "worktree" ? (
...@@ -92,7 +92,12 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe...@@ -92,7 +92,12 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
92 ) : (92 ) : (
93 <FolderIcon className="size-3" />93 <FolderIcon className="size-3" />
94 )}94 )}
95 <SelectValue />95 <span
96 data-composer-label
97 className="min-w-0 max-w-[240px] truncate transition-[max-width,opacity] duration-300 ease-out group-data-[compact]/composer-context:max-w-0 group-data-[compact]/composer-context:opacity-0"
98 >
99 <SelectValue />
100 </span>
96 </SelectTrigger>101 </SelectTrigger>
97 <SelectPopup>102 <SelectPopup>
98 <SelectGroup>103 <SelectGroup>
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+12-2
...@@ -49,7 +49,12 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir...@@ -49,7 +49,12 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
49 ) : (49 ) : (
50 <CloudIcon className="size-3 shrink-0" />50 <CloudIcon className="size-3 shrink-0" />
51 )}51 )}
52 <span className="truncate">{activeEnvironment?.label ?? "Run on"}</span>52 <span
53 data-composer-label
54 className="min-w-0 max-w-[240px] truncate transition-[max-width,opacity] duration-300 ease-out group-data-[compact]/composer-context:max-w-0 group-data-[compact]/composer-context:opacity-0"
55 >
56 {activeEnvironment?.label ?? "Run on"}
57 </span>
53 </span>58 </span>
54 );59 );
55 }60 }
...@@ -72,7 +77,12 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir...@@ -72,7 +77,12 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
72 ) : (77 ) : (
73 <CloudIcon className="size-3 shrink-0" />78 <CloudIcon className="size-3 shrink-0" />
74 )}79 )}
75 <SelectValue />80 <span
81 data-composer-label
82 className="min-w-0 max-w-[240px] truncate transition-[max-width,opacity] duration-300 ease-out group-data-[compact]/composer-context:max-w-0 group-data-[compact]/composer-context:opacity-0"
83 >
84 <SelectValue />
85 </span>
76 </SelectTrigger>86 </SelectTrigger>
77 <SelectPopup>87 <SelectPopup>
78 <SelectGroup>88 <SelectGroup>
apps/web/src/components/ComposerPromptEditor.tsx+5-3
...@@ -1747,12 +1747,14 @@ function ComposerPromptEditorInner({...@@ -1747,12 +1747,14 @@ function ComposerPromptEditorInner({
17471747
1748 return (1748 return (
1749 <ComposerTerminalContextActionsContext value={terminalContextActions}>1749 <ComposerTerminalContextActionsContext value={terminalContextActions}>
1750 <div className="relative">1750 <div className="composer-editor-surface relative">
1751 <PlainTextPlugin1751 <PlainTextPlugin
1752 contentEditable={1752 contentEditable={
1753 <ContentEditable1753 <ContentEditable
1754 className={cn(1754 className={cn(
1755 "block max-h-50 min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent text-[16px] leading-relaxed text-foreground focus:outline-none sm:text-[14px]",1755 // The size comes from .composer-editor-surface so Settings -> Appearance
1756 // can drive it; keep everything else here.
1757 "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",
1756 className,1758 className,
1757 )}1759 )}
1758 data-testid="composer-editor"1760 data-testid="composer-editor"
...@@ -1763,7 +1765,7 @@ function ComposerPromptEditorInner({...@@ -1763,7 +1765,7 @@ function ComposerPromptEditorInner({
1763 }1765 }
1764 placeholder={1766 placeholder={
1765 terminalContexts.length > 0 ? null : (1767 terminalContexts.length > 0 ? null : (
1766 <div className="pointer-events-none absolute inset-0 text-[16px] leading-relaxed text-muted-foreground/35 sm:text-[14px]">1768 <div className="pointer-events-none absolute inset-0 leading-relaxed text-muted-foreground/35">
1767 {placeholder}1769 {placeholder}
1768 </div>1770 </div>
1769 )1771 )
apps/web/src/components/SidebarV2.tsx+3-1
...@@ -757,7 +757,9 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -757,7 +757,9 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
757 type="button"757 type="button"
758 onClick={handlePrClick}758 onClick={handlePrClick}
759 className={cn(759 className={cn(
760 "shrink-0 font-mono text-xs hover:underline",760 // Sidebar chrome follows the interface font; tabular digits keep the
761 // number from reflowing as PR states stream in.
762 "shrink-0 text-xs tabular-nums hover:underline",
761 variant === "slim" && variantAction === "unsettle"763 variant === "slim" && variantAction === "unsettle"
762 ? props.isActive764 ? props.isActive
763 ? "text-muted-foreground/70"765 ? "text-muted-foreground/70"
apps/web/src/components/ThreadTerminalDrawer.tsx+31-1
...@@ -56,6 +56,7 @@ import {...@@ -56,6 +56,7 @@ import {
56 type ThreadTerminalGroup,56 type ThreadTerminalGroup,
57} from "../types";57} from "../types";
58import { readLocalApi } from "~/localApi";58import { readLocalApi } from "~/localApi";
59import { useClientSettings } from "../hooks/useSettings";
59import { useAttachedTerminalSession } from "../state/terminalSessions";60import { useAttachedTerminalSession } from "../state/terminalSessions";
60import { serverEnvironment } from "../state/server";61import { serverEnvironment } from "../state/server";
61import { previewEnvironment } from "../state/preview";62import { previewEnvironment } from "../state/preview";
...@@ -131,7 +132,13 @@ function normalizeComputedColor(value: string | null | undefined, fallback: stri...@@ -131,7 +132,13 @@ function normalizeComputedColor(value: string | null | undefined, fallback: stri
131 return value ?? fallback;132 return value ?? fallback;
132}133}
133134
134function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme {135/** The surface treats an omitted family or size as "use the built-in default". */
136function terminalFontOptions(family: string, size: number): { family?: string; size: number } {
137 const trimmed = family.trim();
138 return trimmed.length > 0 ? { family: trimmed, size } : { size };
139}
140
141export function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme {
135 const isDark = document.documentElement.classList.contains("dark");142 const isDark = document.documentElement.classList.contains("dark");
136 const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)";143 const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)";
137 const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)";144 const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)";
...@@ -305,6 +312,13 @@ export function TerminalViewport({...@@ -305,6 +312,13 @@ export function TerminalViewport({
305 onAddTerminalContext(selection);312 onAddTerminalContext(selection);
306 });313 });
307 const readTerminalLabel = useEffectEvent(() => terminalLabel);314 const readTerminalLabel = useEffectEvent(() => terminalLabel);
315 // The terminal inherits the monospace (code) preference unless it has an
316 // override of its own, so one font choice drives every mono surface.
317 const terminalFontFamily = useClientSettings(
318 (settings) => settings.fontFamilyTerminal.trim() || settings.fontFamilyCode,
319 );
320 const terminalFontSize = useClientSettings((settings) => settings.fontSizeTerminal);
321 const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize });
308 const terminalSession = useAttachedTerminalSession({322 const terminalSession = useAttachedTerminalSession({
309 environmentId,323 environmentId,
310 terminal: {324 terminal: {
...@@ -367,6 +381,13 @@ export function TerminalViewport({...@@ -367,6 +381,13 @@ export function TerminalViewport({
367 keybindingsRef.current = keybindings;381 keybindingsRef.current = keybindings;
368 }, [keybindings]);382 }, [keybindings]);
369383
384 useEffect(() => {
385 const current = terminalFontRef.current;
386 if (current.family === terminalFontFamily && current.size === terminalFontSize) return;
387 terminalFontRef.current = { family: terminalFontFamily, size: terminalFontSize };
388 void terminalRef.current?.setFont(terminalFontOptions(terminalFontFamily, terminalFontSize));
389 }, [terminalFontFamily, terminalFontSize]);
390
370 useEffect(() => {391 useEffect(() => {
371 const mount = containerRef.current;392 const mount = containerRef.current;
372 if (!mount) return;393 if (!mount) return;
...@@ -378,8 +399,10 @@ export function TerminalViewport({...@@ -378,8 +399,10 @@ export function TerminalViewport({
378 let setupCleanups: Array<() => void> = [];399 let setupCleanups: Array<() => void> = [];
379400
380 const setup = async (): Promise<(() => void) | null> => {401 const setup = async (): Promise<(() => void) | null> => {
402 const setupFont = terminalFontRef.current;
381 const terminalOptions: GhosttyTerminalSurfaceOptions = {403 const terminalOptions: GhosttyTerminalSurfaceOptions = {
382 theme: terminalThemeFromApp(mount),404 theme: terminalThemeFromApp(mount),
405 font: terminalFontOptions(setupFont.family, setupFont.size),
383 onData: (data) => handleData(data),406 onData: (data) => handleData(data),
384 onResize: (cols, rows) => void resizeTerminal(cols, rows),407 onResize: (cols, rows) => void resizeTerminal(cols, rows),
385 onSelectionChange: () => handleSelectionChange(),408 onSelectionChange: () => handleSelectionChange(),
...@@ -397,6 +420,13 @@ export function TerminalViewport({...@@ -397,6 +420,13 @@ export function TerminalViewport({
397 terminal.setTheme(terminalThemeFromApp(mount));420 terminal.setTheme(terminalThemeFromApp(mount));
398 setupTerminal = terminal;421 setupTerminal = terminal;
399 terminalRef.current = terminal;422 terminalRef.current = terminal;
423 // Client settings hydrate asynchronously; a font preference that landed
424 // while the surface was loading found terminalRef null, so its setFont
425 // was dropped. Re-apply whatever is current once the terminal exists.
426 const currentFont = terminalFontRef.current;
427 if (currentFont.family !== setupFont.family || currentFont.size !== setupFont.size) {
428 void terminal.setFont(terminalFontOptions(currentFont.family, currentFont.size));
429 }
400 const latestSession = latestSessionRef.current;430 const latestSession = latestSessionRef.current;
401 previousSessionRef.current = latestSession;431 previousSessionRef.current = latestSession;
402 if (latestSession.buffer.length > 0) terminal.resetAndWrite(latestSession.buffer);432 if (latestSession.buffer.length > 0) terminal.resetAndWrite(latestSession.buffer);
apps/web/src/components/composerInlineChip.ts+10-7
...@@ -1,20 +1,23 @@...@@ -1,20 +1,23 @@
1// Chip metrics are in em so the pills scale with the text they sit in (the
2// composer honors the prompt font-size preference). The chat variant pins the
3// original 12px, where every em value resolves to the same pixels as before.
1const INLINE_CHIP_CLASS_NAME =4const INLINE_CHIP_CLASS_NAME =
2 "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";5 "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";
36
4export const CHAT_INLINE_CHIP_CLASS_NAME = INLINE_CHIP_CLASS_NAME;7export const CHAT_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[12px]`;
58
6export const COMPOSER_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} select-none`;9export const COMPOSER_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[0.86em] select-none`;
710
8export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-3.5 shrink-0 opacity-85";11export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-[1.17em] shrink-0 opacity-85";
912
10export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight";13export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight";
1114
12export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = `${CHAT_INLINE_CHIP_LABEL_CLASS_NAME} select-none`;15export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = `${CHAT_INLINE_CHIP_LABEL_CLASS_NAME} select-none`;
1316
14export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME =17export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME =
15 "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";18 "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";
1619
17export const SKILL_CHIP_ICON_SVG = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>`;20export const SKILL_CHIP_ICON_SVG = `<svg width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>`;
1821
19export const COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME =22export const COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME =
20 "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";23 "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";
apps/web/src/components/settings/FontFamilyPicker.tsx created+254
...@@ -0,0 +1,254 @@
1import { LegendList, type LegendListRef } from "@legendapp/list/react";
2import { CheckIcon, ChevronDownIcon, SearchIcon } from "lucide-react";
3import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
4import { isMonospaceFamily, queryInstalledFontFamilies } from "../../appearanceFonts";
5import {
6 Combobox,
7 ComboboxEmpty,
8 ComboboxInput,
9 ComboboxItem,
10 ComboboxListVirtualized,
11 ComboboxPopup,
12 ComboboxTrigger,
13} from "../ui/combobox";
14
15const DEFAULT_FONT_VALUE = "__default__";
16
17function supportsFontEnumeration(): boolean {
18 return (
19 typeof window !== "undefined" &&
20 typeof (window as { queryLocalFonts?: unknown }).queryLocalFonts === "function"
21 );
22}
23
24type FontEnumerationState =
25 | { readonly status: "unknown" }
26 | { readonly status: "granted"; readonly families: readonly string[] }
27 | { readonly status: "unavailable" };
28
29// Shared across every row: once one picker learns the fonts (or learns the
30// permission is blocked), the others follow without re-querying — and the
31// rows can swap to the plain-input control together.
32let enumerationState: FontEnumerationState = supportsFontEnumeration()
33 ? { status: "unknown" }
34 : { status: "unavailable" };
35const enumerationListeners = new Set<() => void>();
36
37function subscribeToEnumeration(listener: () => void): () => void {
38 enumerationListeners.add(listener);
39 return () => enumerationListeners.delete(listener);
40}
41
42function readEnumerationState(): FontEnumerationState {
43 return enumerationState;
44}
45
46let enumerationLoad: Promise<void> | null = null;
47
48/** Query installed fonts; call from a user gesture (the permission prompt needs one). */
49export function discoverInstalledFonts(): void {
50 if (enumerationState.status !== "unknown" || enumerationLoad !== null) return;
51 enumerationLoad = queryInstalledFontFamilies().then((result) => {
52 enumerationState =
53 result.status === "granted"
54 ? { status: "granted", families: result.families }
55 : { status: "unavailable" };
56 enumerationLoad = null;
57 for (const listener of enumerationListeners) listener();
58 });
59}
60
61let grantedProbeStarted = false;
62
63/**
64 * Discover eagerly when the permission is already granted, so the picker
65 * renders without waiting for a focus. Electron's default permission handler
66 * approves silently (it has no prompt UI), and a browser that granted once
67 * reports "granted" on later visits — in both, no user gesture is needed.
68 * "prompt" and "denied" states change nothing: the focus-driven flow stays,
69 * because raising the browser prompt still requires a gesture.
70 */
71function probeAlreadyGrantedPermission(): void {
72 if (grantedProbeStarted || enumerationState.status !== "unknown") return;
73 grantedProbeStarted = true;
74 const permissions = typeof navigator !== "undefined" ? navigator.permissions : undefined;
75 if (typeof permissions?.query !== "function") return;
76 permissions.query({ name: "local-fonts" as PermissionName }).then(
77 (status) => {
78 if (status.state === "granted") discoverInstalledFonts();
79 },
80 () => {
81 // The engine does not recognize the permission name; keep the
82 // focus-driven flow.
83 },
84 );
85}
86
87/**
88 * Whether the engine can list installed fonts (Local Font Access API —
89 * Chromium and Electron). "unknown" until discovery resolves the permission;
90 * rows render a plain family-name input until the state is known granted,
91 * then upgrade to the picker. Where the permission is already granted,
92 * discovery starts at mount and the picker appears without a focus.
93 */
94export function useFontEnumeration(): FontEnumerationState {
95 useEffect(probeAlreadyGrantedPermission, []);
96 return useSyncExternalStore(subscribeToEnumeration, readEnumerationState);
97}
98
99/**
100 * A searchable picker over every installed family, the way native editors
101 * list system fonts. The trigger always names the font in use: the committed
102 * family, or what the default stack resolves to on this machine.
103 */
104export function FontFamilyPicker({
105 ariaLabel,
106 defaultFamily,
107 selectedFamily,
108 requireMonospace = false,
109 initialOpen = false,
110 onSelect,
111}: {
112 ariaLabel: string;
113 /** What an unset preference renders as, e.g. "Menlo". */
114 defaultFamily: string;
115 /** Committed family name; empty string means the default is in use. */
116 selectedFamily: string;
117 requireMonospace?: boolean;
118 /** Open the popup on mount — set when the control upgrades under focus. */
119 initialOpen?: boolean;
120 onSelect: (family: string) => void;
121}) {
122 const [open, setOpen] = useState(false);
123 const [query, setQuery] = useState("");
124 // Open after mount rather than mounting open: a popup that first renders in
125 // its open state never receives Base UI's entrance style baseline, so the
126 // exit transition on close has no style delta, never fires transitionend,
127 // and the popup lingers on screen forever.
128 useEffect(() => {
129 if (initialOpen) setOpen(true);
130 // The prop is only meaningful at mount - the control just swapped in
131 // under an active focus - so later changes are deliberately ignored.
132 // eslint-disable-next-line react-hooks/exhaustive-deps
133 }, []);
134 const listRef = useRef<LegendListRef | null>(null);
135 const enumeration = useFontEnumeration();
136
137 const handleOpenChange = (nextOpen: boolean) => {
138 setOpen(nextOpen);
139 if (nextOpen) setQuery("");
140 };
141
142 const families = useMemo(() => {
143 if (enumeration.status !== "granted") return [];
144 return requireMonospace ? enumeration.families.filter(isMonospaceFamily) : enumeration.families;
145 }, [enumeration, requireMonospace]);
146
147 const items = useMemo(() => {
148 const trimmedQuery = query.trim().toLowerCase();
149 const result: string[] = [];
150 if (trimmedQuery.length === 0) result.push(DEFAULT_FONT_VALUE);
151 result.push(
152 ...families.filter(
153 (family) => trimmedQuery.length === 0 || family.toLowerCase().includes(trimmedQuery),
154 ),
155 );
156 return result;
157 }, [query, families]);
158
159 const selectedValue = selectedFamily.length === 0 ? DEFAULT_FONT_VALUE : selectedFamily;
160
161 const handlePick = (value: string) => {
162 setOpen(false);
163 onSelect(value === DEFAULT_FONT_VALUE ? "" : value);
164 };
165
166 const renderItem = (item: string, index: number) => {
167 const isDefault = item === DEFAULT_FONT_VALUE;
168 const family = isDefault ? defaultFamily : item;
169 return (
170 <ComboboxItem hideIndicator index={index} key={item} value={item}>
171 <div className="flex w-full min-w-0 items-center justify-between gap-2">
172 <span className="min-w-0 truncate" style={{ fontFamily: family }}>
173 {family}
174 </span>
175 <span className="flex shrink-0 items-center gap-1.5">
176 {isDefault ? (
177 <span className="text-[10px] text-muted-foreground/60">default</span>
178 ) : null}
179 {item === selectedValue ? (
180 <CheckIcon className="size-3.5 text-muted-foreground" />
181 ) : null}
182 </span>
183 </div>
184 </ComboboxItem>
185 );
186 };
187
188 return (
189 <Combobox
190 items={items}
191 filteredItems={items}
192 autoHighlight
193 virtualized
194 open={open}
195 onOpenChange={handleOpenChange}
196 value={selectedValue}
197 onValueChange={(next) => {
198 if (typeof next === "string") handlePick(next);
199 }}
200 onItemHighlighted={(_value, eventDetails) => {
201 // Keyboard highlights must pull the virtualized row into view, or
202 // arrow keys walk past the rendered window and navigate blind.
203 if (!open || eventDetails.index < 0 || eventDetails.reason !== "keyboard") return;
204 void listRef.current?.scrollIndexIntoView?.({ index: eventDetails.index, animated: false });
205 }}
206 >
207 <ComboboxTrigger
208 aria-label={ariaLabel}
209 className="relative inline-flex min-h-9 w-full min-w-36 cursor-pointer select-none items-center justify-between gap-2 rounded-lg border border-input bg-background px-[calc(--spacing(3)-1px)] text-left text-base text-foreground shadow-xs/5 outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24 sm:min-h-8 sm:text-sm dark:bg-input/32"
210 >
211 <span className="min-w-0 truncate">
212 {selectedFamily.length === 0 ? defaultFamily : selectedFamily}
213 </span>
214 <ChevronDownIcon className="-me-1 size-3 shrink-0 text-muted-foreground opacity-50" />
215 </ComboboxTrigger>
216 <ComboboxPopup align="end" className="flex w-72 flex-col">
217 <div className="shrink-0 px-3 pt-2.5">
218 <div className="relative -translate-y-px border-b border-border/70 pb-1.5 transition-colors focus-within:border-ring">
219 <SearchIcon
220 aria-hidden="true"
221 className="pointer-events-none absolute top-1.5 left-0 size-4 shrink-0 text-muted-foreground/55"
222 />
223 <ComboboxInput
224 className="[&_input]:h-6.5 [&_input]:ps-5 [&_input]:font-sans [&_input]:leading-6.5"
225 inputClassName="rounded-none bg-transparent text-sm"
226 placeholder="Search fonts…"
227 showTrigger={false}
228 size="sm"
229 unstyled
230 value={query}
231 onChange={(event) => setQuery(event.target.value)}
232 />
233 </div>
234 </div>
235 <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
236 <ComboboxEmpty>No fonts found.</ComboboxEmpty>
237 <div className="relative min-h-0 max-h-72 w-full flex-1 overflow-hidden">
238 <ComboboxListVirtualized className="size-full min-w-0 p-0">
239 <LegendList<string>
240 ref={listRef}
241 data={items}
242 keyExtractor={(item) => item}
243 renderItem={({ item, index }) => renderItem(item, index)}
244 estimatedItemSize={30}
245 drawDistance={360}
246 style={{ height: Math.min(items.length * 30, 288) }}
247 />
248 </ComboboxListVirtualized>
249 </div>
250 </div>
251 </ComboboxPopup>
252 </Combobox>
253 );
254}
apps/web/src/components/settings/SettingsFontPreviews.tsx created+232
...@@ -0,0 +1,232 @@
1import { preloadPatchFile } from "@pierre/diffs/ssr";
2import { useCallback, useEffect, useRef, useState } from "react";
3import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "../ComposerPromptEditor";
4import { terminalThemeFromApp } from "../ThreadTerminalDrawer";
5import { useTheme } from "../../hooks/useTheme";
6import { resolveDiffThemeName, type DiffThemeName } from "../../lib/diffRendering";
7import { GhosttyTerminalSurface } from "~/terminal/ghostty/surface";
8
9// The font previews are the real surfaces, not lookalikes: the composer's
10// Lexical editor, the diff panel's file diff, and the Ghostty canvas
11// renderer. Each already consumes the appearance font tokens (or, for the
12// terminal, the settings passed down as props), so what the row shows is
13// exactly what the app renders.
14
15const EMPTY_TERMINAL_CONTEXTS: ReadonlyArray<never> = [];
16const EMPTY_SKILLS: ReadonlyArray<never> = [];
17
18// Serialized the way the composer stores inline tokens: the $skill and the
19// markdown-style file links render as chips, so the preview shows prompt
20// text and pills exactly as the real composer draws them.
21const PROMPT_PREVIEW_TEXT =
22 "Use $frontend-design to fix the flaky test in " +
23 "[surface.test.ts](apps/web/src/terminal/ghostty/surface.test.ts) and align the header with " +
24 "[SettingsPanels.tsx](apps/web/src/components/settings/SettingsPanels.tsx) before shipping.";
25
26function noop() {}
27
28/** A live composer editor: type in it to feel the family and size. */
29export function PromptFontPreview() {
30 const editorRef = useRef<ComposerPromptEditorHandle>(null);
31 const [prompt, setPrompt] = useState(PROMPT_PREVIEW_TEXT);
32 const [cursor, setCursor] = useState(PROMPT_PREVIEW_TEXT.length);
33 const onChange = useCallback((nextValue: string, nextCursor: number) => {
34 setPrompt(nextValue);
35 setCursor(nextCursor);
36 }, []);
37 return (
38 <div className="mt-1 mb-2 rounded-lg border border-border bg-background px-3 py-2">
39 <ComposerPromptEditor
40 editorRef={editorRef}
41 value={prompt}
42 cursor={cursor}
43 terminalContexts={EMPTY_TERMINAL_CONTEXTS}
44 skills={EMPTY_SKILLS}
45 disabled={false}
46 placeholder="Ask for follow-up changes or attach images"
47 className="max-h-40 min-h-12"
48 onRemoveTerminalContext={noop}
49 onChange={onChange}
50 onPaste={noop}
51 />
52 </div>
53 );
54}
55
56const DIFF_PREVIEW_PATCH = [
57 "diff --git a/src/formatUser.ts b/src/formatUser.ts",
58 "--- a/src/formatUser.ts",
59 "+++ b/src/formatUser.ts",
60 "@@ -1,3 +1,3 @@",
61 " export function formatUser(user: User) {",
62 "- return user.name.toUpperCase();",
63 "+ return `${user.name} <${user.email}>`; // 0O 1lI",
64 " }",
65 "",
66].join("\n");
67
68// Rendered once per theme through the SSR pipeline, which always awaits the
69// shared highlighter before producing HTML. The interactive FileDiff's mount
70// lifecycle can race that highlighter when the typography views remount it
71// (toggling Advanced) and lock in an unhighlighted frame; a static preview
72// needs none of that lifecycle, so it uses the deterministic renderer and
73// injects the finished HTML into a shadow root, exactly as FileDiff would.
74const diffPreviewHtmlByTheme = new Map<DiffThemeName, Promise<readonly string[]>>();
75
76function loadDiffPreviewHtml(theme: DiffThemeName): Promise<readonly string[]> {
77 let promise = diffPreviewHtmlByTheme.get(theme);
78 if (promise === undefined) {
79 promise = preloadPatchFile({
80 patch: DIFF_PREVIEW_PATCH,
81 options: { diffStyle: "unified", theme },
82 }).then((results) => results.map((result) => result.prerenderedHTML));
83 diffPreviewHtmlByTheme.set(theme, promise);
84 }
85 return promise;
86}
87
88function StaticDiffHtml({ html }: { html: string }) {
89 const hostRef = useRef<HTMLDivElement | null>(null);
90 useEffect(() => {
91 const host = hostRef.current;
92 if (host === null) return;
93 const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" });
94 shadow.innerHTML = html;
95 }, [html]);
96 return <div ref={hostRef} />;
97}
98
99/** The diff panel's file diff, statically rendered by its real pipeline. */
100export function CodeFontPreview() {
101 const { resolvedTheme } = useTheme();
102 const themeName = resolveDiffThemeName(resolvedTheme);
103 const [htmlByFile, setHtmlByFile] = useState<readonly string[] | null>(null);
104 useEffect(() => {
105 let cancelled = false;
106 void loadDiffPreviewHtml(themeName).then((html) => {
107 if (!cancelled) setHtmlByFile(html);
108 });
109 return () => {
110 cancelled = true;
111 };
112 }, [themeName]);
113 if (htmlByFile === null) return null;
114 return (
115 <div className="mt-1 mb-2 space-y-2">
116 {htmlByFile.map((html, index) => (
117 <StaticDiffHtml key={index} html={html} />
118 ))}
119 </div>
120 );
121}
122
123const TERMINAL_PROMPT = "\x1b[2m$\x1b[0m ";
124const TERMINAL_PREVIEW_TRANSCRIPT =
125 `${TERMINAL_PROMPT}npm run dev\r\n` +
126 "\x1b[32m✓\x1b[0m Ready in 430ms\r\n" +
127 "\x1b[2mLocal:\x1b[0m \x1b[36mhttp://localhost:3000\x1b[0m\r\n" +
128 TERMINAL_PROMPT;
129
130/** The surface treats an omitted family or size as "use the built-in default". */
131function previewTerminalFont(family: string, size: number): { family?: string; size: number } {
132 const trimmed = family.trim();
133 return trimmed.length > 0 ? { family: trimmed, size } : { size };
134}
135
136/**
137 * The real Ghostty canvas renderer against a local echo loop instead of a
138 * PTY: keys print, Enter starts a new prompt line, Backspace erases. That
139 * exercises the same glyph atlas, cell metrics, and monospace gate the
140 * terminal drawer uses.
141 */
142export function TerminalFontPreview({ family, size }: { family: string; size: number }) {
143 const mountRef = useRef<HTMLDivElement>(null);
144 const surfaceRef = useRef<GhosttyTerminalSurface | null>(null);
145 const fontRef = useRef({ family, size });
146 const { resolvedTheme } = useTheme();
147
148 useEffect(() => {
149 const current = fontRef.current;
150 if (current.family === family && current.size === size) return;
151 fontRef.current = { family, size };
152 void surfaceRef.current?.setFont(previewTerminalFont(family, size));
153 }, [family, size]);
154
155 useEffect(() => {
156 const mount = mountRef.current;
157 const surface = surfaceRef.current;
158 if (!mount || !surface) return;
159 surface.setTheme(terminalThemeFromApp(mount));
160 }, [resolvedTheme]);
161
162 useEffect(() => {
163 const mount = mountRef.current;
164 if (!mount) return;
165 let cancelled = false;
166 // Column of the caret on the current input line, so Backspace stops at
167 // the prompt instead of eating it.
168 let lineLength = 0;
169
170 const echo = (data: string) => {
171 const surface = surfaceRef.current;
172 if (!surface) return;
173 if (data === "\r") {
174 surface.write(`\r\n${TERMINAL_PROMPT}`);
175 lineLength = 0;
176 return;
177 }
178 if (data === "\x7f" || data === "\b") {
179 if (lineLength > 0) {
180 surface.write("\b \b");
181 lineLength -= 1;
182 }
183 return;
184 }
185 // Arrow keys and other escape reports have no cursor to move here.
186 if (data.startsWith("\x1b")) return;
187 const printable = [...data]
188 .filter((character) => character >= " " && character !== "\x7f")
189 .join("");
190 if (printable.length === 0) return;
191 surface.write(printable);
192 lineLength += printable.length;
193 };
194
195 void GhosttyTerminalSurface.create(mount, {
196 theme: terminalThemeFromApp(mount),
197 font: previewTerminalFont(fontRef.current.family, fontRef.current.size),
198 onData: echo,
199 onResize: noop,
200 onSelectionChange: noop,
201 onCopy: (text) => void navigator.clipboard?.writeText(text).catch(noop),
202 // Tab keeps walking the settings page instead of feeding the echo loop.
203 beforeKey: (event) => event.key !== "Tab",
204 onLinkActivate: noop,
205 }).then((surface) => {
206 if (cancelled) {
207 surface.dispose();
208 return;
209 }
210 surfaceRef.current = surface;
211 // The theme and font may both have changed while the WASM surface loaded.
212 surface.setTheme(terminalThemeFromApp(mount));
213 const font = fontRef.current;
214 void surface.setFont(previewTerminalFont(font.family, font.size));
215 surface.write(TERMINAL_PREVIEW_TRANSCRIPT);
216 });
217
218 return () => {
219 cancelled = true;
220 surfaceRef.current?.dispose();
221 surfaceRef.current = null;
222 };
223 }, []);
224
225 return (
226 <div
227 ref={mountRef}
228 className="relative mt-1 mb-2 h-36 overflow-hidden rounded-lg border border-border"
229 aria-label="Terminal font preview"
230 />
231 );
232}
apps/web/src/components/settings/SettingsPanels.tsx+506-28
...@@ -8,8 +8,8 @@ import {...@@ -8,8 +8,8 @@ import {
8 SettingsIcon,8 SettingsIcon,
9} from "lucide-react";9} from "lucide-react";
10import { Link } from "@tanstack/react-router";10import { Link } from "@tanstack/react-router";
11import type { CSSProperties } from "react";11import type { CSSProperties, ReactNode } from "react";
12import { useCallback, useMemo, useRef, useState } from "react";12import { useCallback, useEffect, useMemo, useRef, useState } from "react";
13import { useAtomValue } from "@effect/atom-react";13import { useAtomValue } from "@effect/atom-react";
14import {14import {
15 defaultInstanceIdForDriver,15 defaultInstanceIdForDriver,
...@@ -34,8 +34,16 @@ import {...@@ -34,8 +34,16 @@ import {
34 DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE,34 DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE,
35 DEFAULT_UNIFIED_SETTINGS,35 DEFAULT_UNIFIED_SETTINGS,
36 type EnvironmentIdentificationMode,36 type EnvironmentIdentificationMode,
37 MAX_CODE_FONT_SIZE,
37 MAX_GLASS_OPACITY,38 MAX_GLASS_OPACITY,
39 MAX_INTERFACE_FONT_SIZE,
40 MAX_PROMPT_FONT_SIZE,
41 MAX_TERMINAL_FONT_SIZE,
42 MIN_CODE_FONT_SIZE,
38 MIN_GLASS_OPACITY,43 MIN_GLASS_OPACITY,
44 MIN_INTERFACE_FONT_SIZE,
45 MIN_PROMPT_FONT_SIZE,
46 MIN_TERMINAL_FONT_SIZE,
39} from "@t3tools/contracts/settings";47} from "@t3tools/contracts/settings";
40import {48import {
41 getBackgroundActivityBaseProfile,49 getBackgroundActivityBaseProfile,
...@@ -47,6 +55,7 @@ import * as Arr from "effect/Array";...@@ -47,6 +55,7 @@ import * as Arr from "effect/Array";
47import * as Duration from "effect/Duration";55import * as Duration from "effect/Duration";
48import * as Equal from "effect/Equal";56import * as Equal from "effect/Equal";
49import * as Result from "effect/Result";57import * as Result from "effect/Result";
58import * as Schema from "effect/Schema";
50import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding";59import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding";
51import {60import {
52 canCheckForUpdate,61 canCheckForUpdate,
...@@ -64,6 +73,7 @@ import {...@@ -64,6 +73,7 @@ import {
64import { isElectron } from "../../env";73import { isElectron } from "../../env";
65import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing";74import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing";
66import { useTheme } from "../../hooks/useTheme";75import { useTheme } from "../../hooks/useTheme";
76import { useLocalStorage } from "../../hooks/useLocalStorage";
67import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";77import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
68import { useThreadActions } from "../../hooks/useThreadActions";78import { useThreadActions } from "../../hooks/useThreadActions";
69import { useDesktopUpdateState } from "../../state/desktopUpdate";79import { useDesktopUpdateState } from "../../state/desktopUpdate";
...@@ -77,6 +87,7 @@ import {...@@ -77,6 +87,7 @@ import {
77 sortProviderInstanceEntries,87 sortProviderInstanceEntries,
78} from "../../providerInstances";88} from "../../providerInstances";
79import { ensureLocalApi, readLocalApi } from "../../localApi";89import { ensureLocalApi, readLocalApi } from "../../localApi";
90import { isMacPlatform } from "../../lib/utils";
80import {91import {
81 primaryServerObservabilityAtom,92 primaryServerObservabilityAtom,
82 primaryServerProvidersAtom,93 primaryServerProvidersAtom,
...@@ -97,6 +108,17 @@ import {...@@ -97,6 +108,17 @@ import {
97 DialogTitle,108 DialogTitle,
98} from "../ui/dialog";109} from "../ui/dialog";
99import { DraftInput } from "../ui/draft-input";110import { DraftInput } from "../ui/draft-input";
111import { Input } from "../ui/input";
112import {
113 DEFAULT_CODE_FONT_STACK,
114 DEFAULT_SANS_FONT_STACK,
115 isFontFamilyAvailable,
116 isMonospaceFamily,
117 resolveDefaultFamilyLabel,
118} from "../../appearanceFonts";
119import { DEFAULT_TERMINAL_FONT_FAMILY } from "~/terminal/ghostty/surface";
120import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews";
121import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker";
100import {122import {
101 NumberField,123 NumberField,
102 NumberFieldDecrement,124 NumberFieldDecrement,
...@@ -135,6 +157,7 @@ import {...@@ -135,6 +157,7 @@ import {
135 SettingsRow,157 SettingsRow,
136 SettingsSection,158 SettingsSection,
137 useRelativeTimeTick,159 useRelativeTimeTick,
160 useSettingsSearchTargetId,
138} from "./settingsLayout";161} from "./settingsLayout";
139import { searchableSetting } from "./settingsSearch";162import { searchableSetting } from "./settingsSearch";
140import { ProjectFavicon } from "../ProjectFavicon";163import { ProjectFavicon } from "../ProjectFavicon";
...@@ -586,6 +609,16 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -586,6 +609,16 @@ export function useSettingsRestore(onRestored?: () => void) {
586 ? ["Project Grouping"]609 ? ["Project Grouping"]
587 : []),610 : []),
588 ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []),611 ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []),
612 ...(settings.fontFamilySans !== DEFAULT_UNIFIED_SETTINGS.fontFamilySans
613 ? ["Interface font"]
614 : []),
615 ...(settings.fontFamilyComposer !== DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer
616 ? ["Prompt font"]
617 : []),
618 ...(settings.fontFamilyCode !== DEFAULT_UNIFIED_SETTINGS.fontFamilyCode ? ["Code font"] : []),
619 ...(settings.fontFamilyTerminal !== DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal
620 ? ["Terminal font"]
621 : []),
589 ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace622 ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
590 ? ["Diff whitespace changes"]623 ? ["Diff whitespace changes"]
591 : []),624 : []),
...@@ -629,6 +662,14 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -629,6 +662,14 @@ export function useSettingsRestore(onRestored?: () => void) {
629 settings.newWorktreesStartFromOrigin,662 settings.newWorktreesStartFromOrigin,
630 settings.diffIgnoreWhitespace,663 settings.diffIgnoreWhitespace,
631 settings.environmentIdentificationMode,664 settings.environmentIdentificationMode,
665 settings.fontFamilyCode,
666 settings.fontFamilyComposer,
667 settings.fontFamilySans,
668 settings.fontFamilyTerminal,
669 settings.fontSizeCode,
670 settings.fontSizeInterface,
671 settings.fontSizePrompt,
672 settings.fontSizeTerminal,
632 settings.glassOpacity,673 settings.glassOpacity,
633 settings.enableAssistantStreaming,674 settings.enableAssistantStreaming,
634 settings.enableProviderUpdateChecks,675 settings.enableProviderUpdateChecks,
...@@ -672,6 +713,10 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -672,6 +713,10 @@ export function useSettingsRestore(onRestored?: () => void) {
672 confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive,713 confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive,
673 confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete,714 confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete,
674 textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,715 textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
716 fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans,
717 fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer,
718 fontFamilyCode: DEFAULT_UNIFIED_SETTINGS.fontFamilyCode,
719 fontFamilyTerminal: DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal,
675 });720 });
676 onRestored?.();721 onRestored?.();
677 }, [changedSettingLabels, onRestored, setTheme, updateSettings]);722 }, [changedSettingLabels, onRestored, setTheme, updateSettings]);
...@@ -958,8 +1003,8 @@ export function AppearanceSettingsPanel() {...@@ -958,8 +1003,8 @@ export function AppearanceSettingsPanel() {
958 const glassOpacityRatio =1003 const glassOpacityRatio =
959 (settings.glassOpacity - MIN_GLASS_OPACITY) / (MAX_GLASS_OPACITY - MIN_GLASS_OPACITY);1004 (settings.glassOpacity - MIN_GLASS_OPACITY) / (MAX_GLASS_OPACITY - MIN_GLASS_OPACITY);
960 const glassOpacitySliderStyle = {1005 const glassOpacitySliderStyle = {
961 "--glass-slider-progress": `${glassOpacityRatio * 100}%`,1006 "--settings-slider-progress": `${glassOpacityRatio * 100}%`,
962 "--glass-slider-fill-offset": `${0.5 - glassOpacityRatio}rem`,1007 "--settings-slider-fill-offset": `${0.5 - glassOpacityRatio}rem`,
963 } as CSSProperties;1008 } as CSSProperties;
9641009
965 return (1010 return (
...@@ -1021,7 +1066,7 @@ export function AppearanceSettingsPanel() {...@@ -1021,7 +1066,7 @@ export function AppearanceSettingsPanel() {
1021 </output>1066 </output>
1022 <input1067 <input
1023 aria-label="Glass opacity"1068 aria-label="Glass opacity"
1024 className="glass-opacity-slider min-w-0 flex-1"1069 className="settings-slider min-w-0 flex-1"
1025 id="glass-opacity"1070 id="glass-opacity"
1026 max={MAX_GLASS_OPACITY}1071 max={MAX_GLASS_OPACITY}
1027 min={MIN_GLASS_OPACITY}1072 min={MIN_GLASS_OPACITY}
...@@ -1085,32 +1130,465 @@ export function AppearanceSettingsPanel() {...@@ -1085,32 +1130,465 @@ export function AppearanceSettingsPanel() {
1085 }1130 }
1086 />1131 />
1087 ) : null}1132 ) : null}
1133 </SettingsSection>
10881134
1089 <SettingsRow1135 <TypographySection />
1090 {...searchableSetting("word-wrap")}1136 </SettingsPageContainer>
1091 description="Wrap long lines in code blocks, tables, diffs, and file previews by default."1137 );
1092 resetAction={1138}
1093 settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? (1139
1094 <SettingResetButton1140function useFontDefaultFamilies() {
1095 label="word wrapping"1141 const settings = usePrimarySettings();
1096 onClick={() =>1142 // An unset preference shows the font it resolves to on this machine; the
1097 updateSettings({1143 // default stacks are the platform's own faces, so the name is probed, not
1098 wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,1144 // hardcoded.
1099 })1145 const defaults = useMemo(
1100 }1146 () => ({
1101 />1147 sans: resolveDefaultFamilyLabel(DEFAULT_SANS_FONT_STACK) ?? "System default",
1102 ) : null1148 code: resolveDefaultFamilyLabel(DEFAULT_CODE_FONT_STACK) ?? "System monospace",
1103 }1149 }),
1104 control={1150 [],
1105 <Switch1151 );
1106 checked={settings.wordWrap}1152 return {
1107 onCheckedChange={(checked) => updateSettings({ wordWrap: Boolean(checked) })}1153 sans: defaults.sans,
1108 aria-label="Wrap code, tables, diffs, and file previews by default"1154 code: defaults.code,
1155 // The composer inherits whatever the interface preference resolves to;
1156 // the terminal inherits the monospace preference the same way.
1157 interfaceFamily: settings.fontFamilySans.trim() || defaults.sans,
1158 monoFamily: settings.fontFamilyCode.trim() || defaults.code,
1159 };
1160}
1161
1162function InterfaceFontRow({ preview }: { preview?: ReactNode }) {
1163 const settings = usePrimarySettings();
1164 const updateSettings = useUpdatePrimarySettings();
1165 const defaults = useFontDefaultFamilies();
1166 return (
1167 <FontFamilySettingsRow
1168 {...searchableSetting("interface-font")}
1169 description="Everything outside code blocks and the terminal."
1170 defaultFamily={defaults.sans}
1171 value={settings.fontFamilySans}
1172 onValueChange={(fontFamilySans) => updateSettings({ fontFamilySans })}
1173 size={{
1174 label: "Interface font size",
1175 min: MIN_INTERFACE_FONT_SIZE,
1176 max: MAX_INTERFACE_FONT_SIZE,
1177 value: settings.fontSizeInterface,
1178 onChange: (fontSizeInterface) => updateSettings({ fontSizeInterface }),
1179 }}
1180 {...(preview !== undefined ? { preview } : {})}
1181 />
1182 );
1183}
1184
1185function PromptFontRow() {
1186 const settings = usePrimarySettings();
1187 const updateSettings = useUpdatePrimarySettings();
1188 const defaults = useFontDefaultFamilies();
1189 return (
1190 <FontFamilySettingsRow
1191 {...searchableSetting("prompt-font")}
1192 description="Only the box you write prompts in. Mono works well here."
1193 defaultFamily={defaults.interfaceFamily}
1194 value={settings.fontFamilyComposer}
1195 onValueChange={(fontFamilyComposer) => updateSettings({ fontFamilyComposer })}
1196 size={{
1197 label: "Prompt font size",
1198 min: MIN_PROMPT_FONT_SIZE,
1199 max: MAX_PROMPT_FONT_SIZE,
1200 value: settings.fontSizePrompt,
1201 onChange: (fontSizePrompt) => updateSettings({ fontSizePrompt }),
1202 }}
1203 preview={<PromptFontPreview />}
1204 />
1205 );
1206}
1207
1208function CodeFontRow({
1209 title,
1210 description = "Code blocks, diffs, and file previews.",
1211 preview,
1212}: {
1213 title?: string;
1214 description?: string;
1215 preview?: ReactNode;
1216}) {
1217 const settings = usePrimarySettings();
1218 const updateSettings = useUpdatePrimarySettings();
1219 const defaults = useFontDefaultFamilies();
1220 return (
1221 <FontFamilySettingsRow
1222 {...searchableSetting("code-font")}
1223 {...(title !== undefined ? { title } : {})}
1224 description={description}
1225 defaultFamily={defaults.code}
1226 value={settings.fontFamilyCode}
1227 onValueChange={(fontFamilyCode) => updateSettings({ fontFamilyCode })}
1228 requireMonospace
1229 size={{
1230 label: "Code font size",
1231 min: MIN_CODE_FONT_SIZE,
1232 max: MAX_CODE_FONT_SIZE,
1233 value: settings.fontSizeCode,
1234 onChange: (fontSizeCode) => updateSettings({ fontSizeCode }),
1235 }}
1236 preview={preview ?? <CodeFontPreview />}
1237 />
1238 );
1239}
1240
1241function TerminalFontRow() {
1242 const settings = usePrimarySettings();
1243 const updateSettings = useUpdatePrimarySettings();
1244 const defaults = useFontDefaultFamilies();
1245 return (
1246 <FontFamilySettingsRow
1247 {...searchableSetting("terminal-font")}
1248 description="Terminal output. Follows the monospace font unless set."
1249 defaultFamily={defaults.monoFamily}
1250 value={settings.fontFamilyTerminal}
1251 onValueChange={(fontFamilyTerminal) => updateSettings({ fontFamilyTerminal })}
1252 requireMonospace
1253 size={{
1254 label: "Terminal font size",
1255 min: MIN_TERMINAL_FONT_SIZE,
1256 max: MAX_TERMINAL_FONT_SIZE,
1257 value: settings.fontSizeTerminal,
1258 onChange: (fontSizeTerminal) => updateSettings({ fontSizeTerminal }),
1259 }}
1260 preview={
1261 <TerminalFontPreview
1262 family={settings.fontFamilyTerminal.trim() || settings.fontFamilyCode}
1263 size={settings.fontSizeTerminal}
1264 />
1265 }
1266 />
1267 );
1268}
1269
1270function FontSmoothingRow() {
1271 const settings = usePrimarySettings();
1272 const updateSettings = useUpdatePrimarySettings();
1273 if (!isMacPlatform(navigator.platform)) return null;
1274 return (
1275 <SettingsRow
1276 {...searchableSetting("font-smoothing")}
1277 description="Render text with thinner grayscale anti-aliasing instead of macOS's heavier default."
1278 resetAction={
1279 settings.fontSmoothing !== DEFAULT_UNIFIED_SETTINGS.fontSmoothing ? (
1280 <SettingResetButton
1281 label="font smoothing"
1282 onClick={() =>
1283 updateSettings({ fontSmoothing: DEFAULT_UNIFIED_SETTINGS.fontSmoothing })
1284 }
1285 />
1286 ) : null
1287 }
1288 control={
1289 <Switch
1290 checked={settings.fontSmoothing}
1291 onCheckedChange={(checked) => updateSettings({ fontSmoothing: Boolean(checked) })}
1292 aria-label="Font smoothing"
1293 />
1294 }
1295 />
1296 );
1297}
1298
1299function WordWrapRow() {
1300 const settings = usePrimarySettings();
1301 const updateSettings = useUpdatePrimarySettings();
1302 return (
1303 <SettingsRow
1304 {...searchableSetting("word-wrap")}
1305 description="Wrap long lines in code blocks, tables, diffs, and file previews by default."
1306 resetAction={
1307 settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? (
1308 <SettingResetButton
1309 label="word wrapping"
1310 onClick={() => updateSettings({ wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap })}
1311 />
1312 ) : null
1313 }
1314 control={
1315 <Switch
1316 checked={settings.wordWrap}
1317 onCheckedChange={(checked) => updateSettings({ wordWrap: Boolean(checked) })}
1318 aria-label="Wrap code, tables, diffs, and file previews by default"
1319 />
1320 }
1321 />
1322 );
1323}
1324
1325function FontSettingsGroup() {
1326 return (
1327 <>
1328 <InterfaceFontRow />
1329 <PromptFontRow />
1330 <CodeFontRow />
1331 <TerminalFontRow />
1332 <FontSmoothingRow />
1333 </>
1334 );
1335}
1336
1337/**
1338 * The two-font view: one sans, one monospace. The prompt follows the
1339 * interface font and the terminal follows the monospace font, so the demos
1340 * under each row show every surface the choice reaches.
1341 */
1342function SimpleFontRows() {
1343 const settings = usePrimarySettings();
1344 return (
1345 <>
1346 <InterfaceFontRow preview={<PromptFontPreview />} />
1347 <CodeFontRow
1348 title="Monospace font"
1349 description="Code blocks, diffs, file previews, and the terminal."
1350 preview={
1351 <>
1352 <CodeFontPreview />
1353 <TerminalFontPreview
1354 family={settings.fontFamilyTerminal.trim() || settings.fontFamilyCode}
1355 size={settings.fontSizeTerminal}
1109 />1356 />
1357 </>
1358 }
1359 />
1360 </>
1361 );
1362}
1363
1364// Font smoothing only renders on macOS, so a search jump to it elsewhere
1365// must not flip the section - the target would never mount to be scrolled to.
1366const ADVANCED_TYPOGRAPHY_TARGET_IDS: ReadonlySet<string> = new Set([
1367 "prompt-font",
1368 "terminal-font",
1369 ...(typeof navigator !== "undefined" && isMacPlatform(navigator.platform)
1370 ? ["font-smoothing"]
1371 : []),
1372]);
1373
1374const TYPOGRAPHY_ADVANCED_KEY = "t3code:typography-advanced";
1375
1376/**
1377 * The two-font view by default - one sans, one monospace, each cascading to
1378 * every surface it reaches - with an Advanced switch in the section header
1379 * that reveals the per-surface override rows. The choice persists locally,
1380 * and a settings-search jump to an override row flips Advanced on so the
1381 * target exists to scroll to.
1382 */
1383function TypographySection() {
1384 const [advanced, setAdvanced] = useLocalStorage(TYPOGRAPHY_ADVANCED_KEY, false, Schema.Boolean);
1385 const searchTargetId = useSettingsSearchTargetId();
1386 // Flip Advanced on once per search jump so the hidden target can mount and
1387 // scroll; tracking the handled id lets the user turn it back off without
1388 // the still-set target immediately re-expanding the section.
1389 const lastExpandedTargetRef = useRef<string | null>(null);
1390 useEffect(() => {
1391 if (searchTargetId === null || !ADVANCED_TYPOGRAPHY_TARGET_IDS.has(searchTargetId)) return;
1392 if (lastExpandedTargetRef.current === searchTargetId) return;
1393 lastExpandedTargetRef.current = searchTargetId;
1394 setAdvanced(true);
1395 }, [searchTargetId, setAdvanced]);
1396 return (
1397 <SettingsSection
1398 title="Typography"
1399 headerAction={
1400 <label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-muted-foreground">
1401 Advanced
1402 <Switch
1403 checked={advanced}
1404 onCheckedChange={(checked) => setAdvanced(Boolean(checked))}
1405 aria-label="Show advanced typography settings"
1406 />
1407 </label>
1408 }
1409 >
1410 {advanced ? <FontSettingsGroup /> : <SimpleFontRows />}
1411 <WordWrapRow />
1412 </SettingsSection>
1413 );
1414}
1415
1416function FontFamilySettingsRow({
1417 id,
1418 title,
1419 description,
1420 defaultFamily,
1421 preview,
1422 value,
1423 onValueChange,
1424 requireMonospace = false,
1425 size,
1426}: {
1427 id?: string;
1428 title: string;
1429 description: string;
1430 /** What an unset preference renders as, e.g. "Menlo". */
1431 defaultFamily: string;
1432 preview?: ReactNode;
1433 value: string;
1434 onValueChange: (value: string) => void;
1435 requireMonospace?: boolean;
1436 size: { label: string; min: number; max: number; value: number; onChange: (v: number) => void };
1437}) {
1438 const trimmed = value.trim();
1439 // The fallback input edits a draft; the preference only commits once typing
1440 // pauses and the text probes as an available font (or is an explicit
1441 // clear), so the current font holds and nothing reflows mid-word.
1442 const [draft, setDraft] = useState(value);
1443 const [draftSettled, setDraftSettled] = useState(true);
1444 const commitTimerRef = useRef<number | null>(null);
1445 const lastValueRef = useRef(value);
1446 if (lastValueRef.current !== value) {
1447 // The committed value changed externally (hydration, reset, picker
1448 // selection); adopt it and drop any pending commit of a stale draft.
1449 lastValueRef.current = value;
1450 if (commitTimerRef.current !== null) {
1451 window.clearTimeout(commitTimerRef.current);
1452 commitTimerRef.current = null;
1453 }
1454 setDraft(value);
1455 setDraftSettled(true);
1456 }
1457 useEffect(
1458 () => () => {
1459 if (commitTimerRef.current !== null) window.clearTimeout(commitTimerRef.current);
1460 },
1461 [],
1462 );
1463 const acceptsFamily = (candidate: string) =>
1464 isFontFamilyAvailable(candidate) && (!requireMonospace || isMonospaceFamily(candidate));
1465 const commitDraft = (next: string) => {
1466 setDraftSettled(true);
1467 // A rejected name stays in the field, flagged: the terminal would silently
1468 // fall back to its default, so the row must not claim it took the value.
1469 if (next.trim().length === 0 || acceptsFamily(next)) {
1470 onValueChange(next);
1471 }
1472 };
1473 const flushDraft = () => {
1474 if (commitTimerRef.current === null) return;
1475 window.clearTimeout(commitTimerRef.current);
1476 commitTimerRef.current = null;
1477 commitDraft(draft);
1478 };
1479 const draftTrimmed = draft.trim();
1480 // Flag an unknown name only once typing pauses, and never for an empty
1481 // field - that is the starting state, not a rejected entry.
1482 const draftPending = draftSettled && draftTrimmed.length > 0 && draftTrimmed !== trimmed;
1483 const resetAction =
1484 trimmed.length > 0 ? (
1485 <SettingResetButton
1486 label={`${title.toLowerCase()} family`}
1487 onClick={() => onValueChange("")}
1488 />
1489 ) : null;
1490 const fontEnumeration = useFontEnumeration();
1491 // Everyone starts on the plain input; focusing it is the user gesture that
1492 // runs font discovery. Where the engine can enumerate, the control then
1493 // upgrades to the picker - popped open when the swap happens under focus,
1494 // so the interaction continues without a second click.
1495 const inputFocusedRef = useRef(false);
1496 const familyControl =
1497 fontEnumeration.status === "granted" ? (
1498 <FontFamilyPicker
1499 ariaLabel={`${title} family`}
1500 defaultFamily={defaultFamily}
1501 selectedFamily={trimmed}
1502 requireMonospace={requireMonospace}
1503 initialOpen={inputFocusedRef.current}
1504 onSelect={onValueChange}
1505 />
1506 ) : (
1507 <Input
1508 aria-label={`${title} family`}
1509 aria-invalid={draftPending || undefined}
1510 autoCapitalize="off"
1511 autoComplete="off"
1512 className="min-w-0 flex-1"
1513 maxLength={200}
1514 onFocus={() => {
1515 inputFocusedRef.current = true;
1516 discoverInstalledFonts();
1517 }}
1518 onBlur={() => {
1519 inputFocusedRef.current = false;
1520 flushDraft();
1521 }}
1522 onChange={(event) => {
1523 const next = event.currentTarget.value;
1524 setDraft(next);
1525 setDraftSettled(false);
1526 if (commitTimerRef.current !== null) {
1527 window.clearTimeout(commitTimerRef.current);
1110 }1528 }
1111 />1529 commitTimerRef.current = window.setTimeout(() => {
1112 </SettingsSection>1530 commitTimerRef.current = null;
1113 </SettingsPageContainer>1531 commitDraft(next);
1532 }, 400);
1533 }}
1534 onKeyDown={(event) => {
1535 if (event.key === "Enter") flushDraft();
1536 if (event.key === "Escape") {
1537 // Discard uncommitted typing without closing the settings page,
1538 // which is what an unhandled Escape does.
1539 event.preventDefault();
1540 event.stopPropagation();
1541 if (commitTimerRef.current !== null) {
1542 window.clearTimeout(commitTimerRef.current);
1543 commitTimerRef.current = null;
1544 }
1545 setDraft(value);
1546 setDraftSettled(true);
1547 }
1548 }}
1549 placeholder={defaultFamily}
1550 spellCheck={false}
1551 value={draft}
1552 />
1553 );
1554 const control = (
1555 <div className="flex w-full items-center gap-2 sm:w-auto">
1556 <div className="min-w-0 flex-1 sm:w-44 sm:flex-none">{familyControl}</div>
1557 <Select
1558 value={String(size.value)}
1559 onValueChange={(next) => {
1560 if (typeof next !== "string") return;
1561 const parsed = Number(next);
1562 if (Number.isInteger(parsed) && parsed >= size.min && parsed <= size.max) {
1563 size.onChange(parsed);
1564 }
1565 }}
1566 >
1567 <SelectTrigger className="w-22 shrink-0" aria-label={size.label}>
1568 <SelectValue>{size.value} px</SelectValue>
1569 </SelectTrigger>
1570 <SelectPopup align="end" alignItemWithTrigger={false}>
1571 {Array.from({ length: size.max - size.min + 1 }, (_, index) => size.min + index).map(
1572 (px) => (
1573 <SelectItem hideIndicator key={px} value={String(px)}>
1574 {px} px
1575 </SelectItem>
1576 ),
1577 )}
1578 </SelectPopup>
1579 </Select>
1580 </div>
1581 );
1582 return (
1583 <SettingsRow
1584 {...(id !== undefined ? { id } : {})}
1585 title={title}
1586 description={description}
1587 resetAction={resetAction}
1588 control={control}
1589 >
1590 {preview}
1591 </SettingsRow>
1114 );1592 );
1115}1593}
11161594
apps/web/src/components/settings/settingsLayout.tsx+5
...@@ -62,6 +62,11 @@ function scrollAndFocusSettingsTarget(target: HTMLElement): void {...@@ -62,6 +62,11 @@ function scrollAndFocusSettingsTarget(target: HTMLElement): void {
62 });62 });
63}63}
6464
65/** The row id a settings-search jump is currently trying to reach, if any. */
66export function useSettingsSearchTargetId(): string | null {
67 return useContext(SettingsSearchTargetContext).targetId;
68}
69
65function useSettingsSearchTarget<T extends HTMLElement>(id: string | undefined) {70function useSettingsSearchTarget<T extends HTMLElement>(id: string | undefined) {
66 const { targetId, onTargetHandled } = useContext(SettingsSearchTargetContext);71 const { targetId, onTargetHandled } = useContext(SettingsSearchTargetContext);
67 const isSearchTarget = id !== undefined && id === targetId;72 const isSearchTarget = id !== undefined && id === targetId;
apps/web/src/components/settings/settingsSearch.ts+25
...@@ -55,6 +55,31 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -55,6 +55,31 @@ export const SETTINGS_SEARCH_ITEMS = [
55 // The setting is stage-dependent, so its parent section is the stable destination.55 // The setting is stage-dependent, so its parent section is the stable destination.
56 targetId: "appearance",56 targetId: "appearance",
57 },57 },
58 {
59 id: "interface-font",
60 title: "Interface font",
61 to: "/settings/appearance",
62 },
63 {
64 id: "prompt-font",
65 title: "Prompt font",
66 to: "/settings/appearance",
67 },
68 {
69 id: "code-font",
70 title: "Code font",
71 to: "/settings/appearance",
72 },
73 {
74 id: "terminal-font",
75 title: "Terminal font",
76 to: "/settings/appearance",
77 },
78 {
79 id: "font-smoothing",
80 title: "Font smoothing",
81 to: "/settings/appearance",
82 },
58 {83 {
59 id: "word-wrap",84 id: "word-wrap",
60 title: "Word wrap",85 title: "Word wrap",
apps/web/src/index.css+77-41
...@@ -130,6 +130,15 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -130,6 +130,15 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
130 );130 );
131}131}
132132
133/* The font tokens are declared outside the inline theme so utilities reference
134 the variables and Settings -> Appearance can override them at runtime. The
135 default stacks are mirrored in `appearanceFonts.ts`. */
136@theme {
137 --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
138 --font-mono:
139 ui-monospace, "SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
140}
141
133@theme inline {142@theme inline {
134 --color-zinc-25: oklch(99.2% 0 0);143 --color-zinc-25: oklch(99.2% 0 0);
135 --animate-skeleton: skeleton 2s -1s infinite linear;144 --animate-skeleton: skeleton 2s -1s infinite linear;
...@@ -138,11 +147,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -138,11 +147,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
138 --animate-status-pulse: status-pulse 2s infinite;147 --animate-status-pulse: status-pulse 2s infinite;
139 --animate-status-ping: status-ping 2s infinite;148 --animate-status-ping: status-ping 2s infinite;
140 --animate-sidebar-working-text: sidebar-working-text 3.4s infinite;149 --animate-sidebar-working-text: sidebar-working-text 3.4s infinite;
141 --font-sans:
142 "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,
143 sans-serif;
144 --font-mono:
145 "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;
146 --color-warning-foreground: var(--warning-foreground);150 --color-warning-foreground: var(--warning-foreground);
147 --color-warning: var(--warning);151 --color-warning: var(--warning);
148 --color-success-foreground: var(--success-foreground);152 --color-success-foreground: var(--success-foreground);
...@@ -679,11 +683,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -679,11 +683,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
679 background: color-mix(in srgb, var(--background) 64%, transparent);683 background: color-mix(in srgb, var(--background) 64%, transparent);
680 }684 }
681685
682 .glass-opacity-slider {686 .settings-slider {
683 --glass-slider-progress: 0%;687 --settings-slider-progress: 0%;
684 --glass-slider-fill-offset: 0.5rem;688 --settings-slider-fill-offset: 0.5rem;
685 --glass-slider-fill-position: calc(689 --settings-slider-fill-position: calc(
686 var(--glass-slider-progress) + var(--glass-slider-fill-offset)690 var(--settings-slider-progress) + var(--settings-slider-fill-offset)
687 );691 );
688 appearance: none;692 appearance: none;
689 height: 1.5rem;693 height: 1.5rem;
...@@ -691,32 +695,32 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -691,32 +695,32 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
691 cursor: pointer;695 cursor: pointer;
692 }696 }
693697
694 .glass-opacity-slider::-webkit-slider-runnable-track {698 .settings-slider::-webkit-slider-runnable-track {
695 height: 0.375rem;699 height: 0.375rem;
696 border-radius: 9999px;700 border-radius: 9999px;
697 background: linear-gradient(701 background: linear-gradient(
698 to right,702 to right,
699 var(--primary) 0 var(--glass-slider-fill-position),703 var(--primary) 0 var(--settings-slider-fill-position),
700 color-mix(in srgb, var(--muted-foreground) 22%, transparent) var(--glass-slider-fill-position)704 color-mix(in srgb, var(--muted-foreground) 22%, transparent)
701 100%705 var(--settings-slider-fill-position) 100%
702 );706 );
703 box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border) 55%, transparent);707 box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border) 55%, transparent);
704 }708 }
705709
706 .glass-opacity-slider::-moz-range-track {710 .settings-slider::-moz-range-track {
707 height: 0.375rem;711 height: 0.375rem;
708 border-radius: 9999px;712 border-radius: 9999px;
709 background: color-mix(in srgb, var(--muted-foreground) 22%, transparent);713 background: color-mix(in srgb, var(--muted-foreground) 22%, transparent);
710 box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border) 55%, transparent);714 box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border) 55%, transparent);
711 }715 }
712716
713 .glass-opacity-slider::-moz-range-progress {717 .settings-slider::-moz-range-progress {
714 height: 0.375rem;718 height: 0.375rem;
715 border-radius: 9999px;719 border-radius: 9999px;
716 background: var(--primary);720 background: var(--primary);
717 }721 }
718722
719 .glass-opacity-slider::-webkit-slider-thumb {723 .settings-slider::-webkit-slider-thumb {
720 appearance: none;724 appearance: none;
721 width: 1rem;725 width: 1rem;
722 height: 1rem;726 height: 1rem;
...@@ -730,7 +734,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -730,7 +734,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
730 box-shadow 120ms ease;734 box-shadow 120ms ease;
731 }735 }
732736
733 .glass-opacity-slider::-moz-range-thumb {737 .settings-slider::-moz-range-thumb {
734 width: 1rem;738 width: 1rem;
735 height: 1rem;739 height: 1rem;
736 border: 2px solid var(--primary);740 border: 2px solid var(--primary);
...@@ -742,54 +746,54 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -742,54 +746,54 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
742 box-shadow 120ms ease;746 box-shadow 120ms ease;
743 }747 }
744748
745 .glass-opacity-slider:hover::-webkit-slider-thumb {749 .settings-slider:hover::-webkit-slider-thumb {
746 transform: scale(1.08);750 transform: scale(1.08);
747 box-shadow: 0 1px 3px color-mix(in srgb, var(--foreground) 20%, transparent);751 box-shadow: 0 1px 3px color-mix(in srgb, var(--foreground) 20%, transparent);
748 }752 }
749753
750 .glass-opacity-slider:hover::-moz-range-thumb {754 .settings-slider:hover::-moz-range-thumb {
751 transform: scale(1.08);755 transform: scale(1.08);
752 box-shadow: 0 1px 3px color-mix(in srgb, var(--foreground) 20%, transparent);756 box-shadow: 0 1px 3px color-mix(in srgb, var(--foreground) 20%, transparent);
753 }757 }
754758
755 .glass-opacity-slider:active::-webkit-slider-thumb {759 .settings-slider:active::-webkit-slider-thumb {
756 transform: scale(0.94);760 transform: scale(0.94);
757 }761 }
758762
759 .glass-opacity-slider:active::-moz-range-thumb {763 .settings-slider:active::-moz-range-thumb {
760 transform: scale(0.94);764 transform: scale(0.94);
761 }765 }
762766
763 .glass-opacity-slider:focus-visible {767 .settings-slider:focus-visible {
764 outline: none;768 outline: none;
765 }769 }
766770
767 .glass-opacity-slider:focus-visible::-webkit-slider-thumb {771 .settings-slider:focus-visible::-webkit-slider-thumb {
768 box-shadow:772 box-shadow:
769 0 0 0 3px var(--background),773 0 0 0 3px var(--background),
770 0 0 0 5px var(--ring);774 0 0 0 5px var(--ring);
771 }775 }
772776
773 .glass-opacity-slider:focus-visible::-moz-range-thumb {777 .settings-slider:focus-visible::-moz-range-thumb {
774 box-shadow:778 box-shadow:
775 0 0 0 3px var(--background),779 0 0 0 3px var(--background),
776 0 0 0 5px var(--ring);780 0 0 0 5px var(--ring);
777 }781 }
778782
779 @media (forced-colors: active) {783 @media (forced-colors: active) {
780 .glass-opacity-slider {784 .settings-slider {
781 appearance: auto;785 appearance: auto;
782 accent-color: Highlight;786 accent-color: Highlight;
783 }787 }
784788
785 .glass-opacity-slider::-webkit-slider-runnable-track,789 .settings-slider::-webkit-slider-runnable-track,
786 .glass-opacity-slider::-webkit-slider-thumb {790 .settings-slider::-webkit-slider-thumb {
787 all: revert;791 all: revert;
788 }792 }
789793
790 .glass-opacity-slider::-moz-range-track,794 .settings-slider::-moz-range-track,
791 .glass-opacity-slider::-moz-range-progress,795 .settings-slider::-moz-range-progress,
792 .glass-opacity-slider::-moz-range-thumb {796 .settings-slider::-moz-range-thumb {
793 all: revert;797 all: revert;
794 }798 }
795 }799 }
...@@ -997,14 +1001,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -997,14 +1001,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
997}1001}
9981002
999body {1003body {
1000 font-family:1004 /* Reference the theme token (not a literal stack) so the Settings ->
1001 "DM Sans Variable",1005 Appearance runtime override of --font-sans reaches all interface text. */
1002 "DM Sans",1006 font-family: var(--font-sans);
1003 -apple-system,
1004 BlinkMacSystemFont,
1005 "Segoe UI",
1006 system-ui,
1007 sans-serif;
1008 margin: 0;1007 margin: 0;
1009 padding: 0;1008 padding: 0;
1010}1009}
...@@ -1055,8 +1054,27 @@ body {...@@ -1055,8 +1054,27 @@ body {
10551054
1056pre,1055pre,
1057code {1056code {
1058 font-family:1057 font-family: var(--font-mono);
1059 "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;1058}
1059
1060/* Code blocks in chat carry the size preference. Scoped to chat markdown
1061 rather than every pre: a global rule would beat text-size utilities and
1062 inherited sizes on unrelated pre surfaces (terminal previews, approvals).
1063 Inline code stays relative to its sentence so it never towers over prose;
1064 diffs and file previews take the size through --diffs-font-size. */
1065.chat-markdown .chat-markdown-shiki .shiki,
1066.chat-markdown pre code {
1067 font-size: var(--font-size-code, inherit);
1068}
1069
1070/* @pierre/diffs surfaces (diffs, file previews, annotatable code, search
1071 lines) render inside shadow roots but consult these hooks with their own
1072 literal stacks as fallback. Custom properties inherit across the shadow
1073 boundary, so defining them once here routes every code surface through the
1074 appearance font tokens. */
1075:root {
1076 --diffs-font-family: var(--font-mono);
1077 --diffs-header-font-family: var(--font-sans);
1060}1078}
10611079
1062/* Window drag region (frameless titlebar) */1080/* Window drag region (frameless titlebar) */
...@@ -1090,6 +1108,24 @@ code {...@@ -1090,6 +1108,24 @@ code {
1090 background: var(--app-scrollbar-thumb-hover);1108 background: var(--app-scrollbar-thumb-hover);
1091}1109}
10921110
1111/* Settings -> Appearance can point the composer at its own face (for example a
1112 mono font); default follows the sans stack. Applied on the surface wrapper so
1113 the editor and its placeholder inherit together. */
1114.composer-editor-surface {
1115 font-family: var(--font-composer, var(--font-sans));
1116 font-size: var(--font-size-prompt, 0.875rem);
1117}
1118
1119/* Touch browsers zoom the page when a focused field is under 16px, so keep
1120 the floor there regardless of the preference. Gated on a coarse pointer:
1121 the zoom quirk does not exist on desktop, where a narrow window must not
1122 silently override a smaller chosen prompt size. */
1123@media (max-width: 39.999rem) and (pointer: coarse) {
1124 .composer-editor-surface {
1125 font-size: max(var(--font-size-prompt, 1rem), 16px);
1126 }
1127}
1128
1093.t3-ghostty-canvas {1129.t3-ghostty-canvas {
1094 cursor: text;1130 cursor: text;
1095}1131}
apps/web/src/main.tsx-3
...@@ -5,9 +5,6 @@ import { passkeys } from "@clerk/electron/passkeys";...@@ -5,9 +5,6 @@ import { passkeys } from "@clerk/electron/passkeys";
5import { ClerkProvider as ElectronClerkProvider } from "@clerk/electron/react";5import { ClerkProvider as ElectronClerkProvider } from "@clerk/electron/react";
6import { createHashHistory, createBrowserHistory } from "@tanstack/react-router";6import { createHashHistory, createBrowserHistory } from "@tanstack/react-router";
77
8import "@fontsource-variable/dm-sans/index.css";
9import "@fontsource/jetbrains-mono/400.css";
10import "@fontsource/jetbrains-mono/500.css";
11import "./index.css";8import "./index.css";
129
13import { isElectron } from "./env";10import { isElectron } from "./env";
apps/web/src/routes/__root.tsx+34
...@@ -27,6 +27,7 @@ import {...@@ -27,6 +27,7 @@ import {
27 toastManager,27 toastManager,
28} from "../components/ui/toast";28} from "../components/ui/toast";
29import { resolveAndPersistPreferredEditor } from "../editorPreferences";29import { resolveAndPersistPreferredEditor } from "../editorPreferences";
30import { applyAppearanceFontVariables } from "~/appearanceFonts";
30import { useClientSettings } from "../hooks/useSettings";31import { useClientSettings } from "../hooks/useSettings";
31import {32import {
32 deriveLogicalProjectKeyFromSettings,33 deriveLogicalProjectKeyFromSettings,
...@@ -128,6 +129,7 @@ function RootRouteView() {...@@ -128,6 +129,7 @@ function RootRouteView() {
128 <AnchoredToastProvider>129 <AnchoredToastProvider>
129 <DocumentTitleSync />130 <DocumentTitleSync />
130 <GlassAppearanceSync />131 <GlassAppearanceSync />
132 <FontAppearanceSync />
131 {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null}133 {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null}
132 <RelayClientInstallDialog />134 <RelayClientInstallDialog />
133 <ConnectOnboardingDialog />135 <ConnectOnboardingDialog />
...@@ -152,6 +154,38 @@ function GlassAppearanceSync() {...@@ -152,6 +154,38 @@ function GlassAppearanceSync() {
152 return null;154 return null;
153}155}
154156
157function FontAppearanceSync() {
158 const fontFamilySans = useClientSettings((settings) => settings.fontFamilySans);
159 const fontFamilyCode = useClientSettings((settings) => settings.fontFamilyCode);
160 const fontFamilyComposer = useClientSettings((settings) => settings.fontFamilyComposer);
161 const fontSizeInterface = useClientSettings((settings) => settings.fontSizeInterface);
162 const fontSizePrompt = useClientSettings((settings) => settings.fontSizePrompt);
163 const fontSizeCode = useClientSettings((settings) => settings.fontSizeCode);
164 const fontSmoothing = useClientSettings((settings) => settings.fontSmoothing);
165
166 useEffect(() => {
167 applyAppearanceFontVariables(document.documentElement, {
168 sans: fontFamilySans,
169 code: fontFamilyCode,
170 composer: fontFamilyComposer,
171 sizeInterface: fontSizeInterface,
172 sizePrompt: fontSizePrompt,
173 sizeCode: fontSizeCode,
174 smoothing: fontSmoothing,
175 });
176 }, [
177 fontFamilyCode,
178 fontFamilyComposer,
179 fontFamilySans,
180 fontSizeCode,
181 fontSizeInterface,
182 fontSizePrompt,
183 fontSmoothing,
184 ]);
185
186 return null;
187}
188
155function DocumentTitleSync() {189function DocumentTitleSync() {
156 const primaryServerVersion =190 const primaryServerVersion =
157 useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null;191 useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null;
apps/web/src/terminal/ghostty/surface.test.ts+36
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19 terminalLinkAtPosition,19 terminalLinkAtPosition,
20 terminalContentOriginY,20 terminalContentOriginY,
21 terminalFontFamily,21 terminalFontFamily,
22 fittedTerminalFontSize,
22 terminalFontSize,23 terminalFontSize,
23 terminalWheelArrowData,24 terminalWheelArrowData,
24 terminalWheelDeltaRows,25 terminalWheelDeltaRows,
...@@ -256,6 +257,41 @@ describe("terminal font resolution", () => {...@@ -256,6 +257,41 @@ describe("terminal font resolution", () => {
256 expect(custom.endsWith("monospace")).toBe(true);257 expect(custom.endsWith("monospace")).toBe(true);
257 });258 });
258259
260 it("ignores proportional families the cell grid cannot lay out", () => {
261 // jsdom has no canvas metrics, so the probe answers "monospace" and the
262 // family is kept; the guard is exercised in the browser instead. Assert the
263 // shape stays intact so a rejected face still yields a usable stack.
264 const stack = terminalFontFamily("Helvetica Neue");
265 expect(stack.endsWith("monospace")).toBe(true);
266 });
267
268 it("quotes families the canvas font shorthand would otherwise reject", () => {
269 expect(terminalFontFamily("3270 Nerd Font").startsWith('"3270 Nerd Font", ')).toBe(true);
270 expect(terminalFontFamily("M+ 1m").startsWith('"M+ 1m", ')).toBe(true);
271 expect(terminalFontFamily("Cascadia Code, Menlo").startsWith('"Cascadia Code", Menlo, ')).toBe(
272 true,
273 );
274 expect(terminalFontFamily(" , ")).toBe(DEFAULT_TERMINAL_FONT_FAMILY);
275 });
276
277 it("slides the rendered size down until a full-width grid fits the canvas", () => {
278 // SF Mono-like advance: 0.6em per cell.
279 const cellWidthAt = (size: number) => size * 0.6;
280 // A wide drawer keeps the preference untouched.
281 expect(fittedTerminalFontSize(cellWidthAt, 20, 1140)).toBe(20);
282 // A split pane at the same preference shrinks until 80 columns fit.
283 const fitted = fittedTerminalFontSize(cellWidthAt, 20, 570);
284 expect(fitted).toBeLessThan(20);
285 expect(Math.floor((570 - 8) / cellWidthAt(fitted))).toBeGreaterThanOrEqual(80);
286 // A tiny pane stops at the legibility floor instead of vanishing.
287 expect(fittedTerminalFontSize(cellWidthAt, 20, 220)).toBe(8);
288 // A preference below the floor is honored as-is.
289 expect(fittedTerminalFontSize(cellWidthAt, 6, 220)).toBe(6);
290 // Unmeasured layouts leave the preference alone.
291 expect(fittedTerminalFontSize(cellWidthAt, 14, 0)).toBe(14);
292 expect(fittedTerminalFontSize(() => 0, 14, 600)).toBe(14);
293 });
294
259 it("clamps requested font sizes to the supported range", () => {295 it("clamps requested font sizes to the supported range", () => {
260 expect(terminalFontSize()).toBe(DEFAULT_TERMINAL_FONT_SIZE);296 expect(terminalFontSize()).toBe(DEFAULT_TERMINAL_FONT_SIZE);
261 expect(terminalFontSize(Number.NaN)).toBe(DEFAULT_TERMINAL_FONT_SIZE);297 expect(terminalFontSize(Number.NaN)).toBe(DEFAULT_TERMINAL_FONT_SIZE);
apps/web/src/terminal/ghostty/surface.ts+82-5
...@@ -13,6 +13,7 @@ import {...@@ -13,6 +13,7 @@ import {
13 type GhosttyCellMetrics,13 type GhosttyCellMetrics,
14} from "./renderer";14} from "./renderer";
15import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url";15import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url";
16import { isMonospaceFamily } from "../../appearanceFonts";
1617
17export const DEFAULT_TERMINAL_FONT_SIZE = 12;18export const DEFAULT_TERMINAL_FONT_SIZE = 12;
18const MIN_TERMINAL_FONT_SIZE = 6;19const MIN_TERMINAL_FONT_SIZE = 6;
...@@ -25,10 +26,11 @@ const TERMINAL_GLYPH_FALLBACKS =...@@ -25,10 +26,11 @@ const TERMINAL_GLYPH_FALLBACKS =
25 '"Symbols Nerd Font Mono", "Symbols Nerd Font", "JetBrainsMono Nerd Font", ' +26 '"Symbols Nerd Font Mono", "Symbols Nerd Font", "JetBrainsMono Nerd Font", ' +
26 '"JetBrainsMono NF", "FiraCode Nerd Font", "Hack Nerd Font", "MesloLGS NF", ' +27 '"JetBrainsMono NF", "FiraCode Nerd Font", "Hack Nerd Font", "MesloLGS NF", ' +
27 '"CaskaydiaCove Nerd Font", "PowerlineSymbols", monospace';28 '"CaskaydiaCove Nerd Font", "PowerlineSymbols", monospace';
28// SF Mono where the platform has it (macOS), otherwise the bundled JetBrains29// The platform's own monospace faces; concrete names only, because an
29// Mono webfont, so the default rendering is identical everywhere else.30// unknown keyword (like ui-monospace) makes canvas font shorthand parsing
31// reject the whole string.
30export const DEFAULT_TERMINAL_FONT_FAMILY =32export const DEFAULT_TERMINAL_FONT_FAMILY =
31 '"SF Mono", "SFMono-Regular", "JetBrains Mono", ' + TERMINAL_GLYPH_FALLBACKS;33 '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", ' + TERMINAL_GLYPH_FALLBACKS;
32const CONTENT_PADDING = 4;34const CONTENT_PADDING = 4;
33const MIN_SCROLLBAR_THUMB_HEIGHT = 18;35const MIN_SCROLLBAR_THUMB_HEIGHT = 18;
34/** Half a blink cycle: the visible and hidden phases are equally long. */36/** Half a blink cycle: the visible and hidden phases are equally long. */
...@@ -61,13 +63,69 @@ function ensureTerminalSymbolsFont(): Promise<void> {...@@ -61,13 +63,69 @@ function ensureTerminalSymbolsFont(): Promise<void> {
61 return symbolsFontLoad;63 return symbolsFontLoad;
62}64}
6365
66function quoteTerminalFontFamilies(list: string): string {
67 return list
68 .split(",")
69 .map((name) => {
70 const bare = name.trim();
71 if (bare.length === 0) return "";
72 if (/^(['"]).*\1$/.test(bare)) return bare;
73 if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(bare)) return bare;
74 return `"${bare.replaceAll('"', "")}"`;
75 })
76 .filter((name) => name.length > 0)
77 .join(", ");
78}
79
64export function terminalFontFamily(family?: string): string {80export function terminalFontFamily(family?: string): string {
65 const custom = family?.trim();81 // Quote non-ident names ("3270 Nerd Font", "M+ 1m"): an unquoted one makes
66 if (!custom) return DEFAULT_TERMINAL_FONT_FAMILY;82 // the whole canvas font string invalid and the assignment silently no-ops.
83 const custom = family === undefined ? "" : quoteTerminalFontFamilies(family);
84 if (custom.length === 0) return DEFAULT_TERMINAL_FONT_FAMILY;
85 // The grid places the cursor and selection on one cell advance, so a
86 // proportional face would draw its text narrower than its own cells. Refuse
87 // it here rather than render a ragged grid with a stranded cursor.
88 if (!isMonospaceFamily(custom)) return DEFAULT_TERMINAL_FONT_FAMILY;
67 // A custom face keeps the glyph fallbacks so prompt symbols stay covered.89 // A custom face keeps the glyph fallbacks so prompt symbols stay covered.
68 return `${custom}, ${TERMINAL_GLYPH_FALLBACKS}`;90 return `${custom}, ${TERMINAL_GLYPH_FALLBACKS}`;
69}91}
7092
93/**
94 * Grids narrower than a classic 80-column terminal wrap command output hard,
95 * so the rendered font size follows the canvas width: the preference is the
96 * ceiling, and the size slides down (to a legibility floor) until a full-width
97 * grid fits. A widening pane slides it back up toward the preference.
98 */
99const MIN_TERMINAL_FIT_COLUMNS = 80;
100const MIN_TERMINAL_FIT_FONT_SIZE = 8;
101
102export function fittedTerminalFontSize(
103 cellWidthAt: (size: number) => number,
104 requested: number,
105 mountWidth: number,
106): number {
107 const available = mountWidth - CONTENT_PADDING * 2;
108 if (available <= 0) return requested;
109 const floor = Math.min(requested, MIN_TERMINAL_FIT_FONT_SIZE);
110 const fits = (cellWidth: number) =>
111 cellWidth > 0 && Math.floor(available / cellWidth) >= MIN_TERMINAL_FIT_COLUMNS;
112 let cellWidth = cellWidthAt(requested);
113 if (cellWidth <= 0 || fits(cellWidth)) return requested;
114 // The advance scales linearly with size for monospace faces: jump close to
115 // the fitting size, then settle the remaining rounding one step at a time.
116 const targetCellWidth = available / MIN_TERMINAL_FIT_COLUMNS;
117 let size = Math.max(
118 floor,
119 Math.min(requested, Math.floor((requested * targetCellWidth) / cellWidth)),
120 );
121 while (size > floor) {
122 cellWidth = cellWidthAt(size);
123 if (cellWidth <= 0 || fits(cellWidth)) break;
124 size -= 1;
125 }
126 return size;
127}
128
71export function terminalFontSize(size?: number): number {129export function terminalFontSize(size?: number): number {
72 if (size === undefined || !Number.isFinite(size)) return DEFAULT_TERMINAL_FONT_SIZE;130 if (size === undefined || !Number.isFinite(size)) return DEFAULT_TERMINAL_FONT_SIZE;
73 return Math.max(MIN_TERMINAL_FONT_SIZE, Math.min(MAX_TERMINAL_FONT_SIZE, Math.round(size)));131 return Math.max(MIN_TERMINAL_FONT_SIZE, Math.min(MAX_TERMINAL_FONT_SIZE, Math.round(size)));
...@@ -350,6 +408,7 @@ export class GhosttyTerminalSurface {...@@ -350,6 +408,7 @@ export class GhosttyTerminalSurface {
350 private metrics: GhosttyCellMetrics;408 private metrics: GhosttyCellMetrics;
351 private fontFamily: string;409 private fontFamily: string;
352 private fontSize: number;410 private fontSize: number;
411 private requestedFontSize: number;
353 private fontEpoch = 0;412 private fontEpoch = 0;
354 private readonly resizeObserver: ResizeObserver;413 private readonly resizeObserver: ResizeObserver;
355 private readonly scrollbarThumb: HTMLDivElement;414 private readonly scrollbarThumb: HTMLDivElement;
...@@ -425,6 +484,7 @@ export class GhosttyTerminalSurface {...@@ -425,6 +484,7 @@ export class GhosttyTerminalSurface {
425 this.theme = options.theme;484 this.theme = options.theme;
426 this.fontFamily = terminalFontFamily(options.font?.family);485 this.fontFamily = terminalFontFamily(options.font?.family);
427 this.fontSize = terminalFontSize(options.font?.size);486 this.fontSize = terminalFontSize(options.font?.size);
487 this.requestedFontSize = this.fontSize;
428 this.resizeObserver = new ResizeObserver(() => this.fit());488 this.resizeObserver = new ResizeObserver(() => this.fit());
429 this.installEvents();489 this.installEvents();
430 this.watchDevicePixelRatio();490 this.watchDevicePixelRatio();
...@@ -544,6 +604,7 @@ export class GhosttyTerminalSurface {...@@ -544,6 +604,7 @@ export class GhosttyTerminalSurface {
544 }604 }
545 if (this.disposed || epoch !== this.fontEpoch) return;605 if (this.disposed || epoch !== this.fontEpoch) return;
546 this.fontFamily = fontFamily;606 this.fontFamily = fontFamily;
607 this.requestedFontSize = fontSize;
547 this.fontSize = fontSize;608 this.fontSize = fontSize;
548 this.applyFontMetrics();609 this.applyFontMetrics();
549 }610 }
...@@ -588,6 +649,22 @@ export class GhosttyTerminalSurface {...@@ -588,6 +649,22 @@ export class GhosttyTerminalSurface {
588 const width = this.mount.clientWidth;649 const width = this.mount.clientWidth;
589 const height = this.mount.clientHeight;650 const height = this.mount.clientHeight;
590 if (width <= 0 || height <= 0) return false;651 if (width <= 0 || height <= 0) return false;
652 const fitted = fittedTerminalFontSize(
653 (size) => measureGhosttyCell(this.context, size, this.fontFamily).width,
654 this.requestedFontSize,
655 width,
656 );
657 if (fitted !== this.fontSize) {
658 this.fontSize = fitted;
659 this.metrics = measureGhosttyCell(this.context, this.fontSize, this.fontFamily);
660 // The grid-change branch below resizes the core, but only when the
661 // column count moved; the cell geometry always did, so sync it here.
662 this.core.resize(this.cols, this.rows, this.metrics.width, this.metrics.height);
663 this.inputLeft = -1;
664 this.inputTop = -1;
665 this.forceFullRender = true;
666 this.scrollbarDirty = true;
667 }
591 const ratio = window.devicePixelRatio || 1;668 const ratio = window.devicePixelRatio || 1;
592 const pixelWidth = Math.max(1, Math.round(width * ratio));669 const pixelWidth = Math.max(1, Math.round(width * ratio));
593 const pixelHeight = Math.max(1, Math.round(height * ratio));670 const pixelHeight = Math.max(1, Math.round(height * ratio));
packages/contracts/src/settings.ts+72
...@@ -62,10 +62,54 @@ export const GlassOpacity = Schema.Int.check(...@@ -62,10 +62,54 @@ export const GlassOpacity = Schema.Int.check(
62);62);
63export type GlassOpacity = typeof GlassOpacity.Type;63export type GlassOpacity = typeof GlassOpacity.Type;
64export const DEFAULT_GLASS_OPACITY: GlassOpacity = 80;64export const DEFAULT_GLASS_OPACITY: GlassOpacity = 80;
65/**
66 * Font size preferences, in CSS pixels. The ranges are deliberately narrow:
67 * the interface size scales every rem-based dimension in the app, so the
68 * bounds keep layouts intact rather than offering unusable extremes.
69 */
70export const MIN_INTERFACE_FONT_SIZE = 12;
71export const MAX_INTERFACE_FONT_SIZE = 20;
72export const InterfaceFontSize = Schema.Int.check(
73 Schema.isBetween({ minimum: MIN_INTERFACE_FONT_SIZE, maximum: MAX_INTERFACE_FONT_SIZE }),
74);
75export type InterfaceFontSize = typeof InterfaceFontSize.Type;
76export const DEFAULT_INTERFACE_FONT_SIZE: InterfaceFontSize = 16;
77
78export const MIN_PROMPT_FONT_SIZE = 12;
79export const MAX_PROMPT_FONT_SIZE = 20;
80export const PromptFontSize = Schema.Int.check(
81 Schema.isBetween({ minimum: MIN_PROMPT_FONT_SIZE, maximum: MAX_PROMPT_FONT_SIZE }),
82);
83export type PromptFontSize = typeof PromptFontSize.Type;
84export const DEFAULT_PROMPT_FONT_SIZE: PromptFontSize = 14;
85
86export const MIN_CODE_FONT_SIZE = 10;
87export const MAX_CODE_FONT_SIZE = 18;
88export const CodeFontSize = Schema.Int.check(
89 Schema.isBetween({ minimum: MIN_CODE_FONT_SIZE, maximum: MAX_CODE_FONT_SIZE }),
90);
91export type CodeFontSize = typeof CodeFontSize.Type;
92export const DEFAULT_CODE_FONT_SIZE: CodeFontSize = 13;
93
94export const MIN_TERMINAL_FONT_SIZE = 8;
95export const MAX_TERMINAL_FONT_SIZE = 20;
96export const TerminalFontSize = Schema.Int.check(
97 Schema.isBetween({ minimum: MIN_TERMINAL_FONT_SIZE, maximum: MAX_TERMINAL_FONT_SIZE }),
98);
99export type TerminalFontSize = typeof TerminalFontSize.Type;
100export const DEFAULT_TERMINAL_FONT_SIZE: TerminalFontSize = 12;
101
65export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", "none"]);102export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", "none"]);
66export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type;103export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type;
67export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork";104export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork";
68105
106/**
107 * A user-chosen font family (a single name or a comma-separated list). Empty
108 * means "use the app default"; clients compose their own fallback stacks.
109 */
110export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200));
111export type FontFamilyPreference = typeof FontFamilyPreference.Type;
112
69export const ClientSettingsSchema = Schema.Struct({113export const ClientSettingsSchema = Schema.Struct({
70 autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),114 autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
71 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),115 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
...@@ -80,6 +124,25 @@ export const ClientSettingsSchema = Schema.Struct({...@@ -80,6 +124,25 @@ export const ClientSettingsSchema = Schema.Struct({
80 glassOpacity: GlassOpacity.pipe(124 glassOpacity: GlassOpacity.pipe(
81 Schema.withDecodingDefault(Effect.succeed(DEFAULT_GLASS_OPACITY)),125 Schema.withDecodingDefault(Effect.succeed(DEFAULT_GLASS_OPACITY)),
82 ),126 ),
127 fontSizeInterface: InterfaceFontSize.pipe(
128 Schema.withDecodingDefault(Effect.succeed(DEFAULT_INTERFACE_FONT_SIZE)),
129 ),
130 fontSizePrompt: PromptFontSize.pipe(
131 Schema.withDecodingDefault(Effect.succeed(DEFAULT_PROMPT_FONT_SIZE)),
132 ),
133 fontSizeCode: CodeFontSize.pipe(
134 Schema.withDecodingDefault(Effect.succeed(DEFAULT_CODE_FONT_SIZE)),
135 ),
136 fontSizeTerminal: TerminalFontSize.pipe(
137 Schema.withDecodingDefault(Effect.succeed(DEFAULT_TERMINAL_FONT_SIZE)),
138 ),
139 fontFamilyCode: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
140 fontFamilyComposer: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
141 fontFamilySans: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
142 fontFamilyTerminal: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
143 // Grayscale `-webkit-font-smoothing: antialiased` (thinner strokes);
144 // disabling restores the platform's heavier default. No effect off macOS.
145 fontSmoothing: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
83 // Model favorites. Historically keyed by provider kind, now146 // Model favorites. Historically keyed by provider kind, now
84 // widened to `ProviderInstanceId` so users can favorite a specific model147 // widened to `ProviderInstanceId` so users can favorite a specific model
85 // on a custom provider instance (e.g. "Codex Personal · gpt-5") without148 // on a custom provider instance (e.g. "Codex Personal · gpt-5") without
...@@ -690,6 +753,15 @@ export const ClientSettingsPatch = Schema.Struct({...@@ -690,6 +753,15 @@ export const ClientSettingsPatch = Schema.Struct({
690 diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),753 diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),
691 environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode),754 environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode),
692 glassOpacity: Schema.optionalKey(GlassOpacity),755 glassOpacity: Schema.optionalKey(GlassOpacity),
756 fontSizeInterface: Schema.optionalKey(InterfaceFontSize),
757 fontSizePrompt: Schema.optionalKey(PromptFontSize),
758 fontSizeCode: Schema.optionalKey(CodeFontSize),
759 fontSizeTerminal: Schema.optionalKey(TerminalFontSize),
760 fontFamilyCode: Schema.optionalKey(FontFamilyPreference),
761 fontFamilyComposer: Schema.optionalKey(FontFamilyPreference),
762 fontFamilySans: Schema.optionalKey(FontFamilyPreference),
763 fontFamilyTerminal: Schema.optionalKey(FontFamilyPreference),
764 fontSmoothing: Schema.optionalKey(Schema.Boolean),
693 favorites: Schema.optionalKey(765 favorites: Schema.optionalKey(
694 Schema.Array(766 Schema.Array(
695 Schema.Struct({767 Schema.Struct({
pnpm-lock.yaml-16
...@@ -532,12 +532,6 @@ importers:...@@ -532,12 +532,6 @@ importers:
532 '@effect/atom-react':532 '@effect/atom-react':
533 specifier: 4.0.0-beta.103533 specifier: 4.0.0-beta.103
534 version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(react@19.2.6)(scheduler@0.27.0)534 version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(react@19.2.6)(scheduler@0.27.0)
535 '@fontsource-variable/dm-sans':
536 specifier: ^5.2.8
537 version: 5.2.8
538 '@fontsource/jetbrains-mono':
539 specifier: ^5.2.8
540 version: 5.2.8
541 '@formkit/auto-animate':535 '@formkit/auto-animate':
542 specifier: ^0.9.0536 specifier: ^0.9.0
543 version: 0.9.0537 version: 0.9.0
...@@ -2707,12 +2701,6 @@ packages:...@@ -2707,12 +2701,6 @@ packages:
2707 '@floating-ui/utils@0.2.11':2701 '@floating-ui/utils@0.2.11':
2708 resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}2702 resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
27092703
2710 '@fontsource-variable/dm-sans@5.2.8':
2711 resolution: {integrity: sha512-AxkvMTvNWgfrmlyjiV05vlHYJa+nRQCf1EfvIrQAPBpFJW0O9VTz7oAFr9S3lvbWdmnFoBk7yFqQL86u64nl2g==}
2712
2713 '@fontsource/jetbrains-mono@5.2.8':
2714 resolution: {integrity: sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==}
2715
2716 '@formkit/auto-animate@0.9.0':2704 '@formkit/auto-animate@0.9.0':
2717 resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==}2705 resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==}
27182706
...@@ -12791,10 +12779,6 @@ snapshots:...@@ -12791,10 +12779,6 @@ snapshots:
1279112779
12792 '@floating-ui/utils@0.2.11': {}12780 '@floating-ui/utils@0.2.11': {}
1279312781
12794 '@fontsource-variable/dm-sans@5.2.8': {}
12795
12796 '@fontsource/jetbrains-mono@5.2.8': {}
12797
12798 '@formkit/auto-animate@0.9.0': {}12782 '@formkit/auto-animate@0.9.0': {}
1279912783
12800 '@hono/node-server@1.19.14(hono@4.12.27)':12784 '@hono/node-server@1.19.14(hono@4.12.27)':