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);