FN-6638: stabilize mobile terminal font metrics

Stabilize terminal font metric remeasurement across mobile and attach surfaces.

- Add a shared terminal font metric wait helper that falls back from full font stacks to concrete individual font families before remeasurement.
- Reapply font options, refit, resize, and refresh SessionTerminal after font metrics settle to cover attached CLI terminals.
- Pin text-size adjustment on xterm measurement subtrees so mobile WebKit cannot inflate ASCII cell metrics.
- Extend terminal preference and rendering tests, documentation, and changeset coverage for the recurrence.

Files changed:
 .changeset/fn-6638-terminal-render.md              |   5 +
 .../xterm-async-font-remeasure-paste-dedupe.md     |  20 +++-
 .../dashboard/app/__tests__/terminal-input.test.ts |  26 +++++
 .../dashboard/app/components/SessionTerminal.css   |  10 ++
 .../dashboard/app/components/SessionTerminal.tsx   |  29 ++++++
 .../dashboard/app/components/TerminalModal.css     |  10 ++
 .../dashboard/app/components/TerminalModal.tsx     |  24 ++---
 .../components/__tests__/SessionTerminal.test.tsx  |  41 +++++++-
 .../components/__tests__/TerminalModal.test.tsx    |  38 ++++++++
 .../utils/__tests__/terminalPreferences.test.ts    |  29 +++++-
 .../dashboard/app/utils/terminalPreferences.ts     | 107 +++++++++++++++++++++
 11 files changed, 319 insertions(+), 20 deletions(-)

Fusion-Task-Id: FN-6638

Fusion-Task-Lineage: edbf071d-a0c1-4955-95ff-83e6996f4674
This commit is contained in:
gsxdsm
2026-06-18 13:19:43 -07:00
parent a6a3260fa3
commit 16b6e5decb
11 changed files with 319 additions and 20 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix mobile iOS terminal cell measurement by making xterm font remeasure resilient to strict FontFaceSet shorthand rejection and pinning text-size adjustment on terminal viewports.

View File

@@ -14,10 +14,15 @@ resolution_type: code_fix
severity: high
related_components:
- packages/dashboard/app/components/TerminalModal.tsx
- packages/dashboard/app/components/TerminalModal.css
- packages/dashboard/app/components/SessionTerminal.tsx
- packages/dashboard/app/components/SessionTerminal.css
- packages/dashboard/app/utils/terminalPreferences.ts
- packages/dashboard/app/components/__tests__/TerminalModal.test.tsx
- packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx
- packages/dashboard/app/__tests__/terminal-input.test.ts
- FN-6390
- FN-6638
tags:
- xterm
- font-loading
@@ -33,6 +38,10 @@ tags:
xterm.js measures character-cell geometry when `terminal.open()` runs. If a custom web font is declared with `font-display: swap`, a cold load can let xterm cache fallback-font metrics and then swap to the real font later. The renderer may keep the stale cell width, producing widely spaced glyphs on mobile/DOM-renderer surfaces.
FN-6638 was the fourth recurrence of the mobile wide-cell defect (FN-6390 → FN-6424 → FN-6603 → FN-6638). The FN-6603 font-stack ordering hypothesis was ruled out: the supplied diagnostic measured `66.76px for AGENTS.md` identically for symbols-first, symbols-last, and system-mono stacks, and desktop/mobile-emulated WebKit rendered ASCII tightly while a real iOS Safari screenshot still showed `A G E N T S . m d`. Treat Playwright/desktop WebKit emulation as a blind spot for this class; it can prove CSS contracts and fallback paths but cannot be the acceptance surface.
The recurrence path was stricter real-iOS font/text measurement behavior. A long `document.fonts.load(`${fontSize}px ${resolvedFontFamily}`)` shorthand can reject on iOS WebKit; returning from that catch prevented xterm from reapplying `fontFamily`/`fontSize`, running `fitAddon.fit()`, publishing resize, and refreshing rows. Separately, the xterm measurement subtree lacked `-webkit-text-size-adjust: 100%`, allowing iOS Safari text inflation to perturb cell metrics.
A second pitfall is custom paste handling. If an `attachCustomKeyEventHandler` Cmd/Ctrl+V branch reads `navigator.clipboard.readText()` and forwards that text to the PTY while the browser also performs the native paste into xterm's helper textarea, the same payload reaches `terminal.onData` and is sent twice.
## Solution
@@ -41,11 +50,12 @@ Keep one canonical paste path and remeasure after font resolution.
- Prefer xterm's native helper-textarea paste for Cmd/Ctrl+V; return `true` from the custom key handler so the browser/xterm path runs, and do not read/send clipboard text manually.
- Preserve custom copy behavior only for selected text, where suppressing terminal input is intentional.
- After `terminal.open()`, call `document.fonts.load()` for the terminal font stack and await `document.fonts.ready` when the FontFaceSet API exists.
- After `terminal.open()`, treat FontFaceSet loading as best-effort: try the full stack, fall back to concrete individual families only if the full shorthand rejects, await `document.fonts.ready`, and never let an iOS shorthand rejection skip the later remeasure.
- Guard async remeasure work with the expected session id and current terminal/addon refs so stale font-load promises cannot mutate a disposed or switched terminal.
- Reapply font options, run `fitAddon.fit()`, publish the resized cols/rows, and refresh visible rows once the web font has resolved.
- Reapply font options, run `fitAddon.fit()`, publish the resized cols/rows, and refresh visible rows once the FontFaceSet has settled.
- Pin `-webkit-text-size-adjust: 100%` / `text-size-adjust: 100%` on the xterm host subtree (`.terminal-xterm` and `.cli-session-terminal__viewport`) so iOS Safari cannot inflate DOM/canvas measurement nodes.
`SessionTerminal` is unaffected by the custom-font symptom because it uses a system monospace stack, and unaffected by paste duplication because it does not install a custom paste handler; native xterm paste is its only input path.
`SessionTerminal` is unaffected by paste duplication because it does not install a custom paste handler; native xterm paste is its only input path. It is affected by the font/cell-measurement invariant because it constructs xterm with the same user-selectable font presets and mobile DOM/canvas renderer path, so it must share both the best-effort font-load remeasure and the text-size-adjust pin.
## Regression coverage
@@ -54,6 +64,8 @@ Cover the invariant across terminal surfaces and input paths:
- Keyboard paste on macOS (`metaKey`) and non-mac (`ctrlKey`) returns `true`, does not call `clipboard.readText()`, and sends exactly one PTY input frame via xterm `onData`.
- Native helper-textarea paste without the shortcut handler sends exactly once, covering mobile/iOS context-menu paste.
- A controlled `document.fonts.load()` promise resolving after `terminal.open()` triggers a post-font-load fit, resize, and refresh.
- `SessionTerminal` asserts it uses the system monospace stack, does not attach a custom key handler, and sends one native xterm paste input frame.
- A controlled `document.fonts.load()` rejection (the real-iOS shorthand failure mode) still triggers font option reapply, fit/resize, and refresh for both `TerminalModal` and `SessionTerminal`.
- CSS contract tests assert both xterm host subtrees pin `text-size-adjust` to 100%.
- `SessionTerminal` asserts it uses the shared terminal font presets, does not attach a custom key handler, and sends one native xterm paste input frame.
This avoids downstream byte de-duplication and fixes the two root causes at their renderer/input seams.

View File

@@ -14,6 +14,24 @@ function findHelperTextareaRule(): string {
return match?.[1] ?? "";
}
function findTerminalTextSizingRule(): string {
const match = css.match(/\.terminal-xterm\s*,\s*\.terminal-xterm \*\s*\{([^}]*)\}/);
return match?.[1] ?? "";
}
function findSessionTerminalTextSizingRule(): string {
const match = css.match(
/\.cli-session-terminal__viewport\s*,\s*\.cli-session-terminal__viewport \*\s*\{([^}]*)\}/,
);
return match?.[1] ?? "";
}
function expectTextSizeAdjustPinned(ruleBody: string): void {
expect(ruleBody).not.toBe("");
expect(ruleBody).toMatch(/-webkit-text-size-adjust\s*:\s*100%\s*;/);
expect(ruleBody).toMatch(/text-size-adjust\s*:\s*100%\s*;/);
}
function findTerminalSymbolsFontFaceRule(): string {
const fontFaceRules = css.match(/@font-face\s*\{[^}]*\}/g) ?? [];
return (
@@ -77,6 +95,14 @@ describe("terminal helper textarea CSS contract", () => {
const ruleBody = findHelperTextareaRule();
expect(ruleBody).toMatch(/opacity:\s*0\.01\b/);
});
it("pins iOS text-size adjustment across the xterm measurement subtree", () => {
expectTextSizeAdjustPinned(findTerminalTextSizingRule());
});
it("pins iOS text-size adjustment on the SessionTerminal xterm viewport", () => {
expectTextSizeAdjustPinned(findSessionTerminalTextSizingRule());
});
});
describe("FN-6424 terminal symbols font CSS contract", () => {

View File

@@ -122,6 +122,16 @@
background: var(--terminal-bg, var(--bg));
}
/*
FNXC:Terminal 2026-06-18-07:34:
SessionTerminal hosts the same xterm DOM/canvas measurement subtree as TerminalModal under a different wrapper. Apply the FN-6638 real-iOS text-size-adjust invariant here too so CLI-agent attach terminals do not inherit Safari-inflated ASCII cell metrics while still using the shared font-load remeasure path for every preset and desktop WebGL.
*/
.cli-session-terminal__viewport,
.cli-session-terminal__viewport * {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
.cli-session-terminal__advance-strip {
display: flex;
align-items: center;

View File

@@ -12,6 +12,7 @@ import {
TERMINAL_PREFERENCES_KEY,
readTerminalPreferences,
resolveTerminalFontFamily,
waitForTerminalFontMetrics,
} from "../utils/terminalPreferences";
/**
@@ -401,6 +402,34 @@ export function SessionTerminal({
/* container not measurable yet */
}
void (async () => {
const fontMetricsSettled = await waitForTerminalFontMetrics(
terminalPreferences.fontSize,
resolvedFontFamily,
);
if (
!fontMetricsSettled ||
disposed ||
xtermRef.current !== term ||
fitAddonRef.current !== fitAddon
) {
return;
}
try {
/*
FNXC:Terminal 2026-06-18-07:15:
SessionTerminal shares TerminalModal's real-iOS DOM/canvas measurement path and the same user-selectable font presets. FN-6638 ruled out stack ordering with the 66.76px-identical diagnostic, so this attach surface must also reapply font options and refit after best-effort FontFaceSet settlement even when iOS rejects the multi-family shorthand; WebGL desktop remains safe because the same invalidation path refreshes renderer metrics without changing renderer selection.
*/
term.options.fontFamily = resolvedFontFamily;
term.options.fontSize = terminalPreferences.fontSize;
(fitAddon as unknown as { fit: () => void }).fit();
sendResize(term.cols, term.rows);
term.refresh(0, Math.max(0, term.rows - 1));
} catch {
/* ignore teardown or transient measure failures */
}
})();
// term.onData → input frames (skip entirely when read-only).
if (!readOnly) {
term.onData((data: string) => {

View File

@@ -545,6 +545,16 @@ FN-6603 found that unicode-range scoping is not enough when the symbols face is
padding: var(--space-xs);
}
/*
FNXC:Terminal 2026-06-18-07:04:
Real iOS Safari recurrence #4 kept wide ASCII cells even after unicode-range scoping and symbols-last ordering; the supplied harness measured AGENTS.md at the same 66.76px for every stack, so ordering is ruled out. Pin text-size adjustment across xterm's measurement subtree so mobile WebKit cannot inflate DOM/canvas cell metrics, while WebGL/desktop keep the same 100% text scale and every terminal preset still resolves through terminalPreferences.
*/
.terminal-xterm,
.terminal-xterm * {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
/*
* xterm fit may apply inline pixel heights on the `.xterm` root after
* row/line-height recomputation (e.g. after font-size changes). Keep the root

View File

@@ -24,6 +24,7 @@ import {
clampTerminalFontSize,
readTerminalPreferences,
resolveTerminalFontFamily,
waitForTerminalFontMetrics,
writeTerminalPreferences,
type TerminalPreferences,
type TerminalRenderer,
@@ -490,16 +491,12 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
terminal: XTerm,
fitAddon: InstanceType<typeof import("@xterm/addon-fit").FitAddon>,
) => {
if (typeof document === "undefined" || !document.fonts?.load) {
return;
}
const fontMetricsSettled = await waitForTerminalFontMetrics(
fontSizeRef.current,
resolvedFontFamilyRef.current,
);
try {
await document.fonts.load(`${fontSizeRef.current}px ${resolvedFontFamilyRef.current}`);
await document.fonts.ready;
} catch {
// Font loading support is best-effort; keep the terminal usable if the
// browser rejects due to permissions, syntax, or unsupported APIs.
if (!fontMetricsSettled) {
return;
}
@@ -512,11 +509,10 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
}
try {
// xterm measures cell geometry at open() time. The terminal Nerd Font
// is loaded with font-display: swap, so a cold load can replace the
// fallback font after open(); re-applying font options and fitting after
// FontFaceSet resolution forces the DOM/canvas and WebGL renderers to
// remeasure against the actual glyph metrics.
/*
FNXC:Terminal 2026-06-18-07:23:
FN-6638 recurrence #4 showed the previous symbols-last stack-order fix was inert: the supplied diagnostic measured AGENTS.md at the same 66.76px for symbols-first, symbols-last, and system-mono stacks while real iOS Safari still widened ASCII cells. xterm measures cell geometry at open() time, so after best-effort FontFaceSet settlement we must always reapply the active preset's font options, fit, resize, and refresh; that invalidates stale DOM/canvas metrics on real iOS when the full shorthand is rejected and keeps desktop WebGL using the same renderer-neutral metric refresh.
*/
terminal.options.fontFamily = resolvedFontFamilyRef.current;
terminal.options.fontSize = fontSizeRef.current;
fitAddon.fit();

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
// ── Mock xterm + addon dynamic imports (jsdom has no canvas/WebGL) ──────────
const mockFitAddon = { fit: vi.fn() };
@@ -9,6 +9,7 @@ const mockTerm = {
onData: vi.fn(),
attachCustomKeyEventHandler: vi.fn(),
write: vi.fn((_data: string, cb?: () => void) => cb?.()),
refresh: vi.fn(),
dispose: vi.fn(),
unicode: { activeVersion: "6" },
options: {} as Record<string, unknown>,
@@ -85,8 +86,13 @@ beforeEach(() => {
mockTerm.onData.mockReset();
mockTerm.attachCustomKeyEventHandler.mockClear();
mockTerm.write.mockClear();
mockTerm.refresh.mockClear();
mockTerm.dispose.mockClear();
mockTerm.options = {};
Object.defineProperty(document, "fonts", {
value: undefined,
configurable: true,
});
mockFitAddon.fit.mockClear();
apiMock.mockReset();
apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: false });
@@ -154,6 +160,39 @@ describe("SessionTerminal", () => {
]);
});
it("refits after font settlement even when iOS rejects the font-load shorthand", async () => {
const load = vi.fn(() => Promise.reject(new DOMException("Invalid font shorthand")));
Object.defineProperty(document, "fonts", {
value: {
load,
ready: Promise.resolve(),
},
configurable: true,
});
const fitCallBaseline = mockFitAddon.fit.mock.calls.length;
render(<SessionTerminal sessionId="s1" />);
await waitFor(() => {
expect(FakeWS.instances.length).toBe(1);
expect(load).toHaveBeenCalledWith(
expect.stringContaining("Fusion Terminal Nerd Font Symbols"),
);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
await waitFor(() => {
expect(mockTerm.options.fontFamily).toBe(resolveTerminalFontFamily("nerd-font"));
expect(mockTerm.options.fontSize).toBe(DEFAULT_TERMINAL_PREFERENCES.fontSize);
expect(mockFitAddon.fit.mock.calls.length).toBeGreaterThan(fitCallBaseline);
expect(mockTerm.refresh).toHaveBeenCalledWith(0, mockTerm.rows - 1);
});
});
it("applies validated terminal preferences at xterm init", async () => {
const { Terminal } = await import("@xterm/xterm");
window.localStorage.setItem(

View File

@@ -4694,6 +4694,44 @@ describe("TerminalModal — xterm focus initialization (FN-1602)", () => {
});
});
it("still refits xterm when iOS rejects the multi-family font-load shorthand", async () => {
const load = vi.fn(() => Promise.reject(new DOMException("Invalid font shorthand")));
Object.defineProperty(document, "fonts", {
value: {
load,
ready: Promise.resolve(),
},
configurable: true,
});
const fitCallBaseline = mockFitAddonFit.mock.calls.length;
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(mockTerminalInstance.open).toHaveBeenCalled();
expect(load).toHaveBeenCalledWith(
expect.stringContaining("Fusion Terminal Nerd Font Symbols"),
);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
await waitFor(() => {
expect(mockTerminalInstance.options.fontFamily).toBe(XTERM_FONT_FAMILY);
expect(mockTerminalInstance.options.fontSize).toBe(DEFAULT_TERMINAL_PREFERENCES.fontSize);
expect(mockFitAddonFit.mock.calls.length).toBeGreaterThan(fitCallBaseline);
expect(mockResize).toHaveBeenCalledWith(
mockTerminalInstance.cols,
mockTerminalInstance.rows,
);
expect(mockTerminalInstance.refresh).toHaveBeenCalledWith(0, mockTerminalInstance.rows - 1);
});
});
it("leaves unrelated key handling untouched", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);

View File

@@ -1,9 +1,11 @@
import { beforeEach, describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
DEFAULT_TERMINAL_PREFERENCES,
LEGACY_TERMINAL_FONT_SIZE_KEY,
TERMINAL_PREFERENCES_KEY,
XTERM_FONT_FAMILY,
readTerminalPreferences,
waitForTerminalFontMetrics,
writeTerminalPreferences,
} from "../terminalPreferences";
@@ -87,4 +89,29 @@ describe("terminalPreferences", () => {
expect(readTerminalPreferences()).toEqual(written);
expect(localStorage.getItem(LEGACY_TERMINAL_FONT_SIZE_KEY)).toBe("22");
});
it("keeps terminal font metrics wait best-effort when iOS rejects the full stack shorthand", async () => {
let readyAwaited = false;
const load = vi.fn((font: string) => {
if (font.includes(",")) {
return Promise.reject(new DOMException("Invalid font shorthand"));
}
return Promise.resolve([]);
});
const ready = Promise.resolve().then(() => {
readyAwaited = true;
});
await expect(
waitForTerminalFontMetrics(12, XTERM_FONT_FAMILY, {
load,
ready,
}),
).resolves.toBe(true);
expect(load).toHaveBeenCalledWith(expect.stringContaining("MesloLGS NF"));
expect(load).toHaveBeenCalledWith("12px \"MesloLGS NF\"");
expect(load).toHaveBeenCalledWith("12px \"Fusion Terminal Nerd Font Symbols\"");
expect(readyAwaited).toBe(true);
});
});

View File

@@ -71,6 +71,113 @@ export function resolveTerminalFontFamily(fontFamily: TerminalFontFamily): strin
);
}
const CSS_GENERIC_FONT_FAMILIES = new Set([
"serif",
"sans-serif",
"monospace",
"cursive",
"fantasy",
"system-ui",
"ui-serif",
"ui-sans-serif",
"ui-monospace",
"ui-rounded",
"emoji",
"math",
"fangsong",
]);
type TerminalFontFaceSet = {
load?: (font: string, text?: string) => PromiseLike<unknown>;
ready?: PromiseLike<unknown>;
};
export function splitTerminalFontFamilies(stack: string): string[] {
return stack
.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/)
.map((family) => family.trim())
.filter(Boolean);
}
function normalizeFontFamilyName(family: string): string {
const trimmed = family.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1).trim();
}
return trimmed;
}
function isLoadableConcreteFontFamily(family: string): boolean {
const normalized = normalizeFontFamilyName(family).toLowerCase();
return normalized !== "" && !CSS_GENERIC_FONT_FAMILIES.has(normalized);
}
function getDocumentFonts(): TerminalFontFaceSet | undefined {
if (typeof document === "undefined") {
return undefined;
}
return document.fonts;
}
async function settleFontLoad(fonts: TerminalFontFaceSet, font: string): Promise<boolean> {
if (!fonts.load) {
return false;
}
try {
await fonts.load(font);
return true;
} catch {
// Best-effort: strict iOS FontFaceSet parsing can reject one shorthand while
// later declarations or fonts.ready still give xterm a safe remeasure point.
return false;
}
}
export async function waitForTerminalFontMetrics(
fontSize: number,
fontFamily: string,
fonts: TerminalFontFaceSet | undefined = getDocumentFonts(),
): Promise<boolean> {
if (!fonts?.load) {
return false;
}
const fontSizeCss = `${fontSize}px`;
const declarations = [
`${fontSizeCss} ${fontFamily}`,
...splitTerminalFontFamilies(fontFamily)
.filter(isLoadableConcreteFontFamily)
.map((family) => `${fontSizeCss} ${family}`),
];
/*
FNXC:Terminal 2026-06-18-07:02:
FN-6638 recurrence #4 showed font-stack order was inert: the supplied diagnostic measured AGENTS.md at the same 66.76px with symbols-first, symbols-last, and system-mono stacks while real iOS Safari still rendered wide ASCII cells. Treat FontFaceSet loading as best-effort and always leave callers free to reapply xterm font options; strict iOS WebKit can reject the long multi-family shorthand, and that rejection must not suppress DOM/canvas or WebGL metric invalidation for any preset.
*/
const [fullStackDeclaration, ...individualDeclarations] = declarations;
const fullStackLoaded = fullStackDeclaration
? await settleFontLoad(fonts, fullStackDeclaration)
: false;
if (!fullStackLoaded) {
for (const declaration of individualDeclarations) {
await settleFontLoad(fonts, declaration);
}
}
try {
await fonts.ready;
} catch {
// Continue to xterm remeasure even if the FontFaceSet settles rejected.
}
return true;
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}