From 93101534f2da466d66bd7ba07dcf233313793f9f Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:00:49 +0200 Subject: [PATCH 1/5] fix(web): keep advanced terminal font separate from code --- apps/web/src/appearanceFonts.test.ts | 22 ++++++++++++++ apps/web/src/appearanceFonts.ts | 16 ++++++++++ .../src/components/ThreadTerminalDrawer.tsx | 22 +++++++++++--- .../components/settings/SettingsPanels.tsx | 30 ++++++++++++------- 4 files changed, 76 insertions(+), 14 deletions(-) diff --git a/apps/web/src/appearanceFonts.test.ts b/apps/web/src/appearanceFonts.test.ts index 8467c13c2ce..3b609774329 100644 --- a/apps/web/src/appearanceFonts.test.ts +++ b/apps/web/src/appearanceFonts.test.ts @@ -9,6 +9,7 @@ import { appearanceFontStack, cssFontFamilies, resolveDefaultFamilyLabel, + resolveTerminalFontPreference, } from "./appearanceFonts"; describe("cssFontFamilies", () => { @@ -54,6 +55,27 @@ describe("appearanceFontStack", () => { }); }); +describe("resolveTerminalFontPreference", () => { + it("inherits the code font in simple mode", () => { + expect( + resolveTerminalFontPreference({ advanced: false, code: "Fira Code", terminal: "" }), + ).toBe("Fira Code"); + }); + + it("keeps code and terminal fonts independent in advanced mode", () => { + expect(resolveTerminalFontPreference({ advanced: true, code: "Fira Code", terminal: "" })).toBe( + "", + ); + expect( + resolveTerminalFontPreference({ + advanced: true, + code: "Fira Code", + terminal: "Berkeley Mono", + }), + ).toBe("Berkeley Mono"); + }); +}); + describe("font size clamping", () => { it("keeps sizes inside the ranges the UI can absorb", () => { expect(clampInterfaceFontSize(16)).toBe(16); diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 3fb6c821a1b..0a9e24370a9 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -25,6 +25,22 @@ export const DEFAULT_SANS_FONT_STACK = export const DEFAULT_CODE_FONT_STACK = '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace'; +export const TYPOGRAPHY_ADVANCED_STORAGE_KEY = "t3code:typography-advanced"; + +/** + * Simple typography treats the terminal as another monospace surface. In + * Advanced mode an empty terminal preference means the terminal default, + * keeping later code-font changes isolated to code surfaces. + */ +export function resolveTerminalFontPreference(input: { + readonly advanced: boolean; + readonly code: string; + readonly terminal: string; +}): string { + if (input.advanced) return input.terminal; + return input.terminal.trim() || input.code; +} + function quoteFontFamilyName(name: string): string { const bare = name.trim(); if (bare.length === 0) return ""; diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 914e04b647d..072241426e2 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -18,6 +18,7 @@ import { type ThreadId, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import * as Schema from "effect/Schema"; import { type PointerEvent as ReactPointerEvent, type ReactNode, @@ -57,6 +58,7 @@ import { } from "../types"; import { readLocalApi } from "~/localApi"; import { useClientSettings } from "../hooks/useSettings"; +import { useLocalStorage } from "../hooks/useLocalStorage"; import { useAttachedTerminalSession } from "../state/terminalSessions"; import { serverEnvironment } from "../state/server"; import { previewEnvironment } from "../state/preview"; @@ -64,6 +66,7 @@ import { terminalEnvironment } from "../state/terminal"; import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; import { useAtomCommand } from "../state/use-atom-command"; import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; +import { resolveTerminalFontPreference, TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../appearanceFonts"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; @@ -241,6 +244,7 @@ export function shouldHandleTerminalExit( } interface TerminalViewportProps { + advancedTypography: boolean; threadRef: ScopedThreadRef; threadId: ThreadId; terminalId: string; @@ -264,6 +268,7 @@ interface TerminalLaunchLocation { } export function TerminalViewport({ + advancedTypography, threadRef, threadId, terminalId, @@ -312,10 +317,12 @@ export function TerminalViewport({ onAddTerminalContext(selection); }); const readTerminalLabel = useEffectEvent(() => terminalLabel); - // The terminal inherits the monospace (code) preference unless it has an - // override of its own, so one font choice drives every mono surface. - const terminalFontFamily = useClientSettings( - (settings) => settings.fontFamilyTerminal.trim() || settings.fontFamilyCode, + const terminalFontFamily = useClientSettings((settings) => + resolveTerminalFontPreference({ + advanced: advancedTypography, + code: settings.fontFamilyCode, + terminal: settings.fontFamilyTerminal, + }), ); const terminalFontSize = useClientSettings((settings) => settings.fontSizeTerminal); const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize }); @@ -921,6 +928,11 @@ export default function ThreadTerminalDrawer({ terminalLaunchLocationsById, }: ThreadTerminalDrawerProps) { const isPanel = mode === "panel"; + const [advancedTypography] = useLocalStorage( + TYPOGRAPHY_ADVANCED_STORAGE_KEY, + false, + Schema.Boolean, + ); const controlledDrawerHeight = clampDrawerHeight(height); const [drawerHeightState, setDrawerHeightState] = useState(() => ({ threadId, @@ -1357,6 +1369,7 @@ export default function ThreadTerminalDrawer({ >
updateSettings({ fontFamilyTerminal })} requireMonospace @@ -1258,7 +1258,11 @@ function TerminalFontRow() { }} preview={ } @@ -1350,7 +1354,11 @@ function SimpleFontRows() { <> @@ -1370,8 +1378,6 @@ const ADVANCED_TYPOGRAPHY_TARGET_IDS: ReadonlySet = new Set([ : []), ]); -const TYPOGRAPHY_ADVANCED_KEY = "t3code:typography-advanced"; - /** * The two-font view by default - one sans, one monospace, each cascading to * every surface it reaches - with an Advanced switch in the section header @@ -1380,7 +1386,11 @@ const TYPOGRAPHY_ADVANCED_KEY = "t3code:typography-advanced"; * target exists to scroll to. */ function TypographySection() { - const [advanced, setAdvanced] = useLocalStorage(TYPOGRAPHY_ADVANCED_KEY, false, Schema.Boolean); + const [advanced, setAdvanced] = useLocalStorage( + TYPOGRAPHY_ADVANCED_STORAGE_KEY, + false, + Schema.Boolean, + ); const searchTargetId = useSettingsSearchTargetId(); // Flip Advanced on once per search jump so the hidden target can mount and // scroll; tracking the handled id lets the user turn it back off without From 528aacc87ce33cf7ae98af5e500cfbac0a52ec5e Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:07:53 +0200 Subject: [PATCH 2/5] fix(web): validate terminal fonts after loading --- apps/web/src/appearanceFonts.test.ts | 14 +++++ apps/web/src/appearanceFonts.ts | 28 +++++++-- apps/web/src/terminal/ghostty/surface.test.ts | 40 ++++++++++++- apps/web/src/terminal/ghostty/surface.ts | 59 +++++++++++++++---- 4 files changed, 125 insertions(+), 16 deletions(-) diff --git a/apps/web/src/appearanceFonts.test.ts b/apps/web/src/appearanceFonts.test.ts index 3b609774329..8096f7a6727 100644 --- a/apps/web/src/appearanceFonts.test.ts +++ b/apps/web/src/appearanceFonts.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + areFontAdvancesMonospace, clampCodeFontSize, clampInterfaceFontSize, clampPromptFontSize, @@ -12,6 +13,19 @@ import { resolveTerminalFontPreference, } from "./appearanceFonts"; +describe("areFontAdvancesMonospace", () => { + it("accepts a fixed advance and rejects any proportional glyph", () => { + expect(areFontAdvancesMonospace([10, 10, 10, 10])).toBe(true); + expect(areFontAdvancesMonospace([10, 10, 7, 10])).toBe(false); + expect(areFontAdvancesMonospace([10, 10.02])).toBe(false); + }); + + it("fails open when canvas metrics are unavailable", () => { + expect(areFontAdvancesMonospace([])).toBe(true); + expect(areFontAdvancesMonospace([Number.NaN, Number.NaN])).toBe(true); + }); +}); + describe("cssFontFamilies", () => { it("returns null for effectively empty input", () => { expect(cssFontFamilies("")).toBeNull(); diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 0a9e24370a9..44795aadd81 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -181,6 +181,22 @@ export function isFontFamilyAvailable(family: string): boolean { } } +const MONOSPACE_PROBE_VARIANTS = ["normal 400", "normal 700", "italic 400", "italic 700"] as const; +const MONOSPACE_PROBE_GLYPHS = ["i", "M", "W", "0", "@", "#", ".", " "] as const; +const MONOSPACE_ADVANCE_TOLERANCE = 0.01; + +export function areFontAdvancesMonospace(advances: readonly number[]): boolean { + const reference = advances[0]; + if ( + reference === undefined || + reference <= 0 || + advances.some((advance) => !Number.isFinite(advance) || advance <= 0) + ) { + return true; + } + return advances.every((advance) => Math.abs(advance - reference) < MONOSPACE_ADVANCE_TOLERANCE); +} + /** * Whether a family renders every character on the same advance. Cell-grid * surfaces (the terminal) require this: a proportional face draws its text @@ -198,13 +214,15 @@ export function isMonospaceFamily(family: string): boolean { fontProbeContext = document.createElement("canvas").getContext("2d"); } if (fontProbeContext === null) return true; + const context = fontProbeContext; // Fall back to a generic mono so an absent face measures as monospace and // is left for the normal fallback chain to resolve. - fontProbeContext.font = `32px ${families}, monospace`; - const narrow = fontProbeContext.measureText("i").width; - const wide = fontProbeContext.measureText("M").width; - if (!Number.isFinite(narrow) || !Number.isFinite(wide) || wide === 0) return true; - return Math.abs(wide - narrow) < 0.5; + for (const variant of MONOSPACE_PROBE_VARIANTS) { + context.font = `${variant} 32px ${families}, monospace`; + const advances = MONOSPACE_PROBE_GLYPHS.map((glyph) => context.measureText(glyph).width); + if (!areFontAdvancesMonospace(advances)) return false; + } + return true; } catch { return true; } diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index f1559ac1578..0faf8d3b8e4 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import type { GhosttyCell, GhosttyRow } from "./core"; import { @@ -11,6 +11,7 @@ import { isTerminalCopyShortcut, isTerminalLinkPointerGesture, isTerminalPasteShortcut, + loadTerminalFontFamily, shouldBlinkTerminalCursor, shouldReportTerminalMouse, shouldShowTerminalLinkHover, @@ -295,6 +296,43 @@ describe("application mouse reporting", () => { }); describe("terminal font resolution", () => { + it("validates the requested face after its styles load", async () => { + let loaded = false; + const context = { + font: "", + measureText(text: string) { + const proportional = loaded && this.font.includes("Proportional Test"); + return { width: proportional && text === "i" ? 6 : 10 } as TextMetrics; + }, + }; + const getContext = vi + .spyOn(HTMLCanvasElement.prototype, "getContext") + .mockReturnValue(context as never); + const load = vi.fn(async () => { + loaded = true; + return []; + }); + const previousFonts = Object.getOwnPropertyDescriptor(document, "fonts"); + Object.defineProperty(document, "fonts", { + configurable: true, + value: { load }, + }); + + try { + await expect(loadTerminalFontFamily("Proportional Test", 12)).resolves.toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + expect(load).toHaveBeenCalledTimes(4); + } finally { + getContext.mockRestore(); + if (previousFonts === undefined) { + Reflect.deleteProperty(document, "fonts"); + } else { + Object.defineProperty(document, "fonts", previousFonts); + } + } + }); + it("keeps the glyph fallbacks behind a custom text face", () => { expect(terminalFontFamily()).toBe(DEFAULT_TERMINAL_FONT_FAMILY); expect(terminalFontFamily(" ")).toBe(DEFAULT_TERMINAL_FONT_FAMILY); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 853da9c139c..3d320ca7749 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -36,6 +36,13 @@ const CONTENT_PADDING = 4; const MIN_SCROLLBAR_THUMB_HEIGHT = 18; /** Half a blink cycle: the visible and hidden phases are equally long. */ const CURSOR_BLINK_INTERVAL_MS = 500; +const TERMINAL_FONT_LOAD_TEXT = "iMW0@# ."; +const TERMINAL_FONT_LOAD_VARIANTS = [ + "normal 400", + "normal 700", + "italic 400", + "italic 700", +] as const; /** Requested terminal font; omitted fields fall back to the defaults. */ export interface GhosttyTerminalFont { @@ -78,6 +85,13 @@ function quoteTerminalFontFamilies(list: string): string { .join(", "); } +function uncheckedTerminalFontFamily(family?: string): string { + const custom = family === undefined ? "" : quoteTerminalFontFamilies(family); + return custom.length === 0 + ? DEFAULT_TERMINAL_FONT_FAMILY + : `${custom}, ${TERMINAL_GLYPH_FALLBACKS}`; +} + export function terminalFontFamily(family?: string): string { // Quote non-ident names ("3270 Nerd Font", "M+ 1m"): an unquoted one makes // the whole canvas font string invalid and the assignment silently no-ops. @@ -88,7 +102,25 @@ export function terminalFontFamily(family?: string): string { // it here rather than render a ragged grid with a stranded cursor. if (!isMonospaceFamily(custom)) return DEFAULT_TERMINAL_FONT_FAMILY; // A custom face keeps the glyph fallbacks so prompt symbols stay covered. - return `${custom}, ${TERMINAL_GLYPH_FALLBACKS}`; + return uncheckedTerminalFontFamily(custom); +} + +/** Load every style the renderer can request, then validate the actual face. */ +export async function loadTerminalFontFamily( + family: string | undefined, + size: number, +): Promise { + const candidate = uncheckedTerminalFontFamily(family); + try { + await Promise.all( + TERMINAL_FONT_LOAD_VARIANTS.map((variant) => + document.fonts.load(`${variant} ${size}px ${candidate}`, TERMINAL_FONT_LOAD_TEXT), + ), + ); + } catch { + // The fixed-width fallback stack remains available if a face cannot load. + } + return terminalFontFamily(family); } /** @@ -479,6 +511,7 @@ export class GhosttyTerminalSurface { private readonly options: GhosttyTerminalSurfaceOptions; private metrics: GhosttyCellMetrics; private fontFamily: string; + private requestedFontFamily: string | undefined; private fontSize: number; private requestedFontSize: number; private fontEpoch = 0; @@ -545,6 +578,7 @@ export class GhosttyTerminalSurface { context: CanvasRenderingContext2D, core: GhosttyTerminalCore, metrics: GhosttyCellMetrics, + fontFamily: string, options: GhosttyTerminalSurfaceOptions, ) { this.mount = mount; @@ -557,7 +591,8 @@ export class GhosttyTerminalSurface { this.metrics = metrics; this.options = options; this.theme = options.theme; - this.fontFamily = terminalFontFamily(options.font?.family); + this.fontFamily = fontFamily; + this.requestedFontFamily = options.font?.family; this.fontSize = terminalFontSize(options.font?.size); this.requestedFontSize = this.fontSize; this.resizeObserver = new ResizeObserver(() => this.fit()); @@ -600,16 +635,15 @@ export class GhosttyTerminalSurface { const context = canvas.getContext("2d", { alpha: false }); if (!context) throw new Error("Canvas 2D is unavailable"); - const fontFamily = terminalFontFamily(options.font?.family); const fontSize = terminalFontSize(options.font?.size); try { // Cell metrics must come from the faces that will render; measuring before // the bundled webfonts load would size the grid from a fallback font. await ensureTerminalSymbolsFont(); - await document.fonts.load(`${fontSize}px ${fontFamily}`); } catch { // Metrics fall back to whichever faces are already available. } + const fontFamily = await loadTerminalFontFamily(options.font?.family, fontSize); const metrics = measureGhosttyCell(context, fontSize, fontFamily); const grid = terminalGridSize(mount.clientWidth, mount.clientHeight, metrics, CONTENT_PADDING); const core = await GhosttyTerminalCore.create( @@ -629,6 +663,7 @@ export class GhosttyTerminalSurface { context, core, metrics, + fontFamily, options, ); surface.fit(); @@ -667,18 +702,14 @@ export class GhosttyTerminalSurface { async setFont(font: GhosttyTerminalFont): Promise { if (this.disposed) return; - const fontFamily = terminalFontFamily(font.family); const fontSize = terminalFontSize(font.size); // The fields only change together with their metrics after the load, and // the epoch lets the newest overlapping call win regardless of load order. const epoch = ++this.fontEpoch; - try { - await document.fonts.load(`${fontSize}px ${fontFamily}`); - } catch { - // Metrics fall back to whichever faces are already available. - } + const fontFamily = await loadTerminalFontFamily(font.family, fontSize); if (this.disposed || epoch !== this.fontEpoch) return; this.fontFamily = fontFamily; + this.requestedFontFamily = font.family; this.requestedFontSize = fontSize; this.fontSize = fontSize; this.applyFontMetrics(); @@ -706,6 +737,14 @@ export class GhosttyTerminalSurface { private readonly onFontsLoaded = () => { if (this.disposed) return; + // A face may become available after an earlier fallback measurement. Run + // the fixed-width guard again before using its newly loaded metrics. + const fontFamily = terminalFontFamily(this.requestedFontFamily); + if (fontFamily !== this.fontFamily) { + this.fontFamily = fontFamily; + this.applyFontMetrics(); + return; + } // A face that finished loading after the initial measurement changes glyph // advances; re-measure and refit so the grid matches what actually renders. const metrics = measureGhosttyCell(this.context, this.fontSize, this.fontFamily); From 32ffeec085939b459692bb16f70909d97c48ee08 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:14:57 +0200 Subject: [PATCH 3/5] test(web): keep terminal font test environment-neutral --- apps/web/src/terminal/ghostty/surface.test.ts | 38 ++++++------------- apps/web/src/terminal/ghostty/surface.ts | 10 ++++- 2 files changed, 19 insertions(+), 29 deletions(-) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 0faf8d3b8e4..d20a6cfe005 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -298,39 +298,23 @@ describe("application mouse reporting", () => { describe("terminal font resolution", () => { it("validates the requested face after its styles load", async () => { let loaded = false; - const context = { - font: "", - measureText(text: string) { - const proportional = loaded && this.font.includes("Proportional Test"); - return { width: proportional && text === "i" ? 6 : 10 } as TextMetrics; - }, - }; - const getContext = vi - .spyOn(HTMLCanvasElement.prototype, "getContext") - .mockReturnValue(context as never); const load = vi.fn(async () => { loaded = true; return []; }); - const previousFonts = Object.getOwnPropertyDescriptor(document, "fonts"); - Object.defineProperty(document, "fonts", { - configurable: true, - value: { load }, + const resolve = vi.fn(() => { + expect(loaded).toBe(true); + return DEFAULT_TERMINAL_FONT_FAMILY; }); - try { - await expect(loadTerminalFontFamily("Proportional Test", 12)).resolves.toBe( - DEFAULT_TERMINAL_FONT_FAMILY, - ); - expect(load).toHaveBeenCalledTimes(4); - } finally { - getContext.mockRestore(); - if (previousFonts === undefined) { - Reflect.deleteProperty(document, "fonts"); - } else { - Object.defineProperty(document, "fonts", previousFonts); - } - } + await expect( + loadTerminalFontFamily("Proportional Test", 12, { + load, + resolve, + }), + ).resolves.toBe(DEFAULT_TERMINAL_FONT_FAMILY); + expect(load).toHaveBeenCalledTimes(4); + expect(resolve).toHaveBeenCalledWith("Proportional Test"); }); it("keeps the glyph fallbacks behind a custom text face", () => { diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 3d320ca7749..eadad2a72c1 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -109,18 +109,24 @@ export function terminalFontFamily(family?: string): string { export async function loadTerminalFontFamily( family: string | undefined, size: number, + environment?: { + readonly load: (font: string, text: string) => Promise; + readonly resolve: (family: string | undefined) => string; + }, ): Promise { const candidate = uncheckedTerminalFontFamily(family); + const load = + environment?.load ?? ((font: string, text: string) => document.fonts.load(font, text)); try { await Promise.all( TERMINAL_FONT_LOAD_VARIANTS.map((variant) => - document.fonts.load(`${variant} ${size}px ${candidate}`, TERMINAL_FONT_LOAD_TEXT), + load(`${variant} ${size}px ${candidate}`, TERMINAL_FONT_LOAD_TEXT), ), ); } catch { // The fixed-width fallback stack remains available if a face cannot load. } - return terminalFontFamily(family); + return (environment?.resolve ?? terminalFontFamily)(family); } /** From 3838e5334fbef743b71a1ba6e07a73d7b6d5bdc6 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:18:14 +0200 Subject: [PATCH 4/5] fix(web): restore unified font in simple mode --- apps/web/src/appearanceFonts.test.ts | 7 +++++++ apps/web/src/appearanceFonts.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/src/appearanceFonts.test.ts b/apps/web/src/appearanceFonts.test.ts index 8096f7a6727..5c642e33f0f 100644 --- a/apps/web/src/appearanceFonts.test.ts +++ b/apps/web/src/appearanceFonts.test.ts @@ -74,6 +74,13 @@ describe("resolveTerminalFontPreference", () => { expect( resolveTerminalFontPreference({ advanced: false, code: "Fira Code", terminal: "" }), ).toBe("Fira Code"); + expect( + resolveTerminalFontPreference({ + advanced: false, + code: "Fira Code", + terminal: "Berkeley Mono", + }), + ).toBe("Fira Code"); }); it("keeps code and terminal fonts independent in advanced mode", () => { diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 44795aadd81..60801ef0118 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -38,7 +38,7 @@ export function resolveTerminalFontPreference(input: { readonly terminal: string; }): string { if (input.advanced) return input.terminal; - return input.terminal.trim() || input.code; + return input.code; } function quoteFontFamilyName(name: string): string { From 6d25c324422a9fb6376e7a396ce5cc7b1c9b363b Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:26:06 +0200 Subject: [PATCH 5/5] fix(web): avoid font revalidation race --- apps/web/src/terminal/ghostty/surface.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index eadad2a72c1..8ff92c01c0c 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -521,6 +521,7 @@ export class GhosttyTerminalSurface { private fontSize: number; private requestedFontSize: number; private fontEpoch = 0; + private pendingFontEpoch: number | null = null; private readonly resizeObserver: ResizeObserver; private readonly scrollbarThumb: HTMLDivElement; private snapshot: GhosttySnapshot | null = null; @@ -712,8 +713,10 @@ export class GhosttyTerminalSurface { // The fields only change together with their metrics after the load, and // the epoch lets the newest overlapping call win regardless of load order. const epoch = ++this.fontEpoch; + this.pendingFontEpoch = epoch; const fontFamily = await loadTerminalFontFamily(font.family, fontSize); if (this.disposed || epoch !== this.fontEpoch) return; + this.pendingFontEpoch = null; this.fontFamily = fontFamily; this.requestedFontFamily = font.family; this.requestedFontSize = fontSize; @@ -743,6 +746,9 @@ export class GhosttyTerminalSurface { private readonly onFontsLoaded = () => { if (this.disposed) return; + // The explicit load validates every style and applies the newest request. + // Its own loading events must not revalidate the previously applied face. + if (this.pendingFontEpoch !== null) return; // A face may become available after an earlier fallback measurement. Run // the fixed-width guard again before using its newly loaded metrics. const fontFamily = terminalFontFamily(this.requestedFontFamily);