FN-6603: fix mobile terminal font measurement

Stabilize terminal glyph measurement by keeping monospace text fonts ahead of symbols fallbacks.

- Move the Fusion Terminal Nerd Font Symbols face behind real monospace fonts in the shared xterm font stack and terminal presets.
- Document the mobile WebKit cell-measurement hazard and keep SessionTerminal aligned with the shared preference path.
- Add regression coverage for mobile wide-glyph spacing, preference presets, and terminal input rendering.
- Add a patch changeset for the published CLI bundle.

Files changed:
 .changeset/fn-6603-terminal-render.md              |  5 +++
 .../xterm-symbols-nerd-font-unicode-range.md       | 17 ++++++---
 .../dashboard/app/__tests__/terminal-input.test.ts | 42 ++++++++++++++++++++--
 .../dashboard/app/components/SessionTerminal.tsx   |  3 ++
 .../dashboard/app/components/TerminalModal.css     |  5 ++-
 .../__tests__/SessionTerminal.mobile.test.tsx      | 22 +++++++++++-
 .../components/__tests__/SessionTerminal.test.tsx  | 23 ++++++++++--
 .../dashboard/app/utils/terminalPreferences.ts     | 14 +++++---
 8 files changed, 115 insertions(+), 16 deletions(-)

Fusion-Task-Id: FN-6603

Fusion-Task-Lineage: b5d90064-1bfd-4da9-8d89-c95cf6fcec0c
This commit is contained in:
gsxdsm
2026-06-17 18:48:53 -07:00
parent 19aac38c51
commit 1bd8f6de6f
8 changed files with 115 additions and 16 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix mobile terminal cell measurement by making xterm font stacks use real monospace text faces before the Nerd Font symbols fallback.

View File

@@ -10,7 +10,7 @@ symptoms:
- "Terminal glyphs render with oversized inter-character spacing after the symbols font loads" - "Terminal glyphs render with oversized inter-character spacing after the symbols font loads"
- "Mobile DOM/canvas xterm output wraps after very few columns even for ASCII commands" - "Mobile DOM/canvas xterm output wraps after very few columns even for ASCII commands"
- "Powerline prompt glyphs are needed, but ASCII must measure against a real monospace text font" - "Powerline prompt glyphs are needed, but ASCII must measure against a real monospace text font"
root_cause: symbols_only_font_face_without_unicode_range_participated_in_ascii_cell_measurement root_cause: symbols_only_font_face_without_unicode_range_or_symbols_first_stack_participated_in_ascii_cell_measurement
resolution_type: code_fix resolution_type: code_fix
severity: high severity: high
related_components: related_components:
@@ -20,6 +20,7 @@ related_components:
- packages/dashboard/app/__tests__/terminal-input.test.ts - packages/dashboard/app/__tests__/terminal-input.test.ts
- FN-6390 - FN-6390
- FN-6424 - FN-6424
- FN-6603
tags: tags:
- xterm - xterm
- font-loading - font-loading
@@ -35,9 +36,14 @@ tags:
A symbols-only Nerd Font can corrupt xterm.js cell measurement when it appears first in the terminal `fontFamily` stack. FN-6390 correctly added an async post-font-load remeasure, but FN-6424 found the recurrence: the browser could still measure ASCII cells against `SymbolsNerdFontMono` after `font-display: swap`, producing huge gaps such as `p n p m b u i l d` on mobile. A symbols-only Nerd Font can corrupt xterm.js cell measurement when it appears first in the terminal `fontFamily` stack. FN-6390 correctly added an async post-font-load remeasure, but FN-6424 found the recurrence: the browser could still measure ASCII cells against `SymbolsNerdFontMono` after `font-display: swap`, producing huge gaps such as `p n p m b u i l d` on mobile.
FN-6603 found the third recurrence: the FN-6390 remeasure and FN-6424 `unicode-range` were both present, but the shared terminal preference stack still listed the symbols face first. Mobile WebKit/xterm canvas measurement could still use that first face for cell metrics while actual ASCII glyph rendering fell through to a later monospace font. The visible symptom was the same wide-cell layout (`A G E N T S . m d`) with intact powerline glyphs.
## Solution ## Solution
Keep the symbols font available for powerline/Nerd-Font codepoints, but scope its `@font-face` with `unicode-range` so printable ASCII is never resolved or measured through that family. Keep the symbols font available for powerline/Nerd-Font codepoints, but apply both guards:
1. Scope its `@font-face` with `unicode-range` so printable ASCII is never resolved through that family during normal glyph fallback.
2. Keep real monospace text faces before the symbols family in every xterm `fontFamily` preset. The symbols family should be a fallback, not the first measurement candidate, because xterm's DOM/canvas metrics path is less reliable than normal DOM text fallback on mobile WebKit.
Use the standard Symbols Nerd Font ranges, including powerline and private-use blocks, for example: Use the standard Symbols Nerd Font ranges, including powerline and private-use blocks, for example:
@@ -50,7 +56,7 @@ Use the standard Symbols Nerd Font ranges, including powerline and private-use b
} }
``` ```
Do not replace this with fixed `letterSpacing`, hardcoded column counts, or by removing the async remeasure. xterm should still refit after web fonts load; the font face itself must prevent symbols-only metrics from applying to ASCII. Do not replace this with fixed `letterSpacing`, hardcoded column counts, or by removing the async remeasure. xterm should still refit after web fonts load; the font face and stack ordering together must prevent symbols-only metrics from applying to ASCII.
## Regression coverage ## Regression coverage
@@ -59,5 +65,6 @@ Automated jsdom tests cannot validate font advance widths, so cover the enforcea
- Parse emitted/app CSS and assert the terminal symbols `@font-face` has a `unicode-range`. - Parse emitted/app CSS and assert the terminal symbols `@font-face` has a `unicode-range`.
- Assert the range contains required Nerd-Font/powerline blocks such as `U+E0A0-E0D7`, `U+E700-E8EF`, and `U+F0001-F1AF0`. - Assert the range contains required Nerd-Font/powerline blocks such as `U+E0A0-E0D7`, `U+E700-E8EF`, and `U+F0001-F1AF0`.
- Assert no range overlaps printable ASCII (`U+0020-007E`). - Assert no range overlaps printable ASCII (`U+0020-007E`).
- Check sibling xterm surfaces: `SessionTerminal` is unaffected if it uses a system monospace stack and does not include the symbols font. - Assert the shared default stack and every terminal font preset place a real text monospace face before `"Fusion Terminal Nerd Font Symbols"`.
- Verify in a mobile/touch browser path that ASCII output renders tightly while the powerline glyph still renders. - Check every xterm consumer: `TerminalModal` and `SessionTerminal` both use `resolveTerminalFontFamily()`, so both are affected by stack ordering and both need component-level coverage that the stack passed to `new Terminal(...)` is measurement-safe.
- Verify in a mobile/touch browser path that ASCII output renders tightly while the powerline glyph still renders for the default `nerd-font` and `system-mono` presets.

View File

@@ -1,7 +1,9 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { loadAllAppCss } from "../test/cssFixture"; import { loadAllAppCss } from "../test/cssFixture";
import { readFileSync } from "fs"; import {
import { resolve } from "path"; TERMINAL_FONT_FAMILY_PRESETS,
XTERM_FONT_FAMILY,
} from "../utils/terminalPreferences";
const css = loadAllAppCss(); const css = loadAllAppCss();
@@ -89,3 +91,39 @@ describe("FN-6424 terminal symbols font CSS contract", () => {
expect(unicodeRanges.some(unicodeRangeIncludesAsciiPrintable)).toBe(false); expect(unicodeRanges.some(unicodeRangeIncludesAsciiPrintable)).toBe(false);
}); });
}); });
describe("FN-6603 terminal font stack measurement contract", () => {
const symbolsFamily = '"Fusion Terminal Nerd Font Symbols"';
function splitFontFamilies(stack: string): string[] {
return stack
.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/)
.map((family) => family.trim())
.filter(Boolean);
}
it("keeps the default symbols fallback after real monospace text fonts", () => {
const families = splitFontFamilies(XTERM_FONT_FAMILY);
const symbolsIndex = families.indexOf(symbolsFamily);
const firstTextFontIndex = families.findIndex((family) => family !== symbolsFamily);
expect(symbolsIndex).toBeGreaterThan(-1);
expect(firstTextFontIndex).toBeGreaterThan(-1);
expect(symbolsIndex).toBeGreaterThan(firstTextFontIndex);
});
it("gives every terminal font preset a measurement-safe text face before symbols", () => {
for (const preset of TERMINAL_FONT_FAMILY_PRESETS) {
const families = splitFontFamilies(preset.css);
const symbolsIndex = families.indexOf(symbolsFamily);
const firstTextFontIndex = families.findIndex((family) => family !== symbolsFamily);
expect(firstTextFontIndex, `${preset.id} has a text font`).toBeGreaterThan(-1);
if (symbolsIndex >= 0) {
expect(symbolsIndex, `${preset.id} symbols fallback order`).toBeGreaterThan(
firstTextFontIndex,
);
}
}
});
});

View File

@@ -343,6 +343,9 @@ export function SessionTerminal({
const resolvedFontFamily = resolveTerminalFontFamily(terminalPreferences.fontFamily); const resolvedFontFamily = resolveTerminalFontFamily(terminalPreferences.fontFamily);
/* /*
FNXC:Terminal 2026-06-17-18:25:
SessionTerminal shares the FN-6603 wide-cell hazard because it passes the same resolved font stack to xterm's mobile DOM/canvas renderer. The shared terminalPreferences stack keeps real monospace faces before the symbols fallback so this attach surface inherits the durable cell-measurement fix instead of relying on a separate SessionTerminal-only font path.
FNXC:Terminal 2026-06-17-00:50: FNXC:Terminal 2026-06-17-00:50:
SessionTerminal consumes the shared localStorage terminal preferences for parity with TerminalModal, but replay safety still owns input posture: cursor blink is the user preference AND-gated by !readOnly && mode === "live" so read-only, idle, and ended sessions never blink. SessionTerminal consumes the shared localStorage terminal preferences for parity with TerminalModal, but replay safety still owns input posture: cursor blink is the user preference AND-gated by !readOnly && mode === "live" so read-only, idle, and ended sessions never blink.
*/ */

View File

@@ -1,6 +1,9 @@
/* /*
FNXC:Terminal 2026-06-13-20:02: FNXC:Terminal 2026-06-13-20:02:
The symbols-only Nerd Font is listed first in the xterm font stack so powerline prompt glyphs resolve before platform monospace fonts. It must stay unicode-range-scoped to Nerd Font codepoints only; otherwise mobile DOM/canvas xterm can measure ASCII cells against the symbols font after font-display: swap and render commands like `pnpm build` with oversized inter-character spacing. The symbols-only Nerd Font must stay unicode-range-scoped to Nerd Font codepoints only; otherwise mobile DOM/canvas xterm can measure ASCII cells against the symbols font after font-display: swap and render commands like `pnpm build` with oversized inter-character spacing.
FNXC:Terminal 2026-06-17-18:12:
FN-6603 found that unicode-range scoping is not enough when the symbols face is first: iOS canvas measurement can still use that first face for xterm cell metrics while DOM glyph fallback draws ASCII from a later monospace font. Keep text fonts first in terminalPreferences and this symbols face as a fallback so powerline glyphs remain available without corrupting ASCII cell width.
*/ */
@font-face { @font-face {
font-family: "Fusion Terminal Nerd Font Symbols"; font-family: "Fusion Terminal Nerd Font Symbols";

View File

@@ -93,7 +93,26 @@ function stubScreen(width: number, height: number) {
} }
import { SessionTerminal } from "../SessionTerminal"; import { SessionTerminal } from "../SessionTerminal";
import { DEFAULT_TERMINAL_PREFERENCES, TERMINAL_PREFERENCES_KEY } from "../../utils/terminalPreferences"; import {
DEFAULT_TERMINAL_PREFERENCES,
TERMINAL_PREFERENCES_KEY,
TERMINAL_SYMBOLS_FONT_FAMILY,
} from "../../utils/terminalPreferences";
function splitFontFamilies(stack: string): string[] {
return stack
.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/)
.map((family) => family.trim())
.filter(Boolean);
}
function expectMeasurementSafeFontStack(stack: string): void {
const families = splitFontFamilies(stack);
const symbolsIndex = families.indexOf(TERMINAL_SYMBOLS_FONT_FAMILY);
const firstTextIndex = families.findIndex((family) => family !== TERMINAL_SYMBOLS_FONT_FAMILY);
expect(firstTextIndex).toBeGreaterThan(-1);
expect(symbolsIndex).toBeGreaterThan(firstTextIndex);
}
/** Pull the parsed input frames a WS has sent. */ /** Pull the parsed input frames a WS has sent. */
function inputFrames(ws: FakeWS): string[] { function inputFrames(ws: FakeWS): string[] {
@@ -318,6 +337,7 @@ describe("SessionTerminal (mobile)", () => {
await renderMobile(); await renderMobile();
expect(WebglAddon).not.toHaveBeenCalled(); expect(WebglAddon).not.toHaveBeenCalled();
expectMeasurementSafeFontStack(mockTerm.options.fontFamily as string);
}); });
it("keeps the accessory key bar intact while applying terminal preferences", async () => { it("keeps the accessory key bar intact while applying terminal preferences", async () => {

View File

@@ -56,8 +56,25 @@ import { SessionTerminal } from "../SessionTerminal";
import { import {
DEFAULT_TERMINAL_PREFERENCES, DEFAULT_TERMINAL_PREFERENCES,
TERMINAL_PREFERENCES_KEY, TERMINAL_PREFERENCES_KEY,
TERMINAL_SYMBOLS_FONT_FAMILY,
resolveTerminalFontFamily,
} from "../../utils/terminalPreferences"; } from "../../utils/terminalPreferences";
function splitFontFamilies(stack: string): string[] {
return stack
.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/)
.map((family) => family.trim())
.filter(Boolean);
}
function expectMeasurementSafeFontStack(stack: string): void {
const families = splitFontFamilies(stack);
const symbolsIndex = families.indexOf(TERMINAL_SYMBOLS_FONT_FAMILY);
const firstTextIndex = families.findIndex((family) => family !== TERMINAL_SYMBOLS_FONT_FAMILY);
expect(firstTextIndex).toBeGreaterThan(-1);
expect(symbolsIndex).toBeGreaterThan(firstTextIndex);
}
beforeEach(() => { beforeEach(() => {
FakeWS.instances = []; FakeWS.instances = [];
originalWebSocket = (globalThis as typeof globalThis & { WebSocket?: typeof WebSocket }).WebSocket; originalWebSocket = (globalThis as typeof globalThis & { WebSocket?: typeof WebSocket }).WebSocket;
@@ -123,6 +140,7 @@ describe("SessionTerminal", () => {
cursorBlink: DEFAULT_TERMINAL_PREFERENCES.cursorBlink, cursorBlink: DEFAULT_TERMINAL_PREFERENCES.cursorBlink,
}), }),
); );
expectMeasurementSafeFontStack(mockTerm.options.fontFamily as string);
expect(mockTerm.attachCustomKeyEventHandler).not.toHaveBeenCalled(); expect(mockTerm.attachCustomKeyEventHandler).not.toHaveBeenCalled();
const inputHandler = mockTerm.onData.mock.calls[0]?.[0] as const inputHandler = mockTerm.onData.mock.calls[0]?.[0] as
@@ -154,7 +172,7 @@ describe("SessionTerminal", () => {
await waitFor(() => expect(FakeWS.instances.length).toBe(1)); await waitFor(() => expect(FakeWS.instances.length).toBe(1));
expect(Terminal).toHaveBeenCalledWith( expect(Terminal).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', fontFamily: resolveTerminalFontFamily("system-mono"),
fontSize: 18, fontSize: 18,
cursorStyle: "underline", cursorStyle: "underline",
cursorBlink: true, cursorBlink: true,
@@ -249,8 +267,7 @@ describe("SessionTerminal", () => {
await waitFor(() => { await waitFor(() => {
expect(mockTerm.options).toMatchObject({ expect(mockTerm.options).toMatchObject({
fontFamily: fontFamily: resolveTerminalFontFamily("jetbrains-mono"),
'"JetBrains Mono", "JetBrainsMono Nerd Font", ui-monospace, SFMono-Regular, monospace',
fontSize: 20, fontSize: 20,
cursorStyle: "bar", cursorStyle: "bar",
cursorBlink: false, cursorBlink: false,

View File

@@ -4,8 +4,14 @@ export const DEFAULT_TERMINAL_FONT_SIZE = 14;
export const MIN_TERMINAL_FONT_SIZE = 8; export const MIN_TERMINAL_FONT_SIZE = 8;
export const MAX_TERMINAL_FONT_SIZE = 32; export const MAX_TERMINAL_FONT_SIZE = 32;
export const TERMINAL_SYMBOLS_FONT_FAMILY = '"Fusion Terminal Nerd Font Symbols"';
/*
FNXC:Terminal 2026-06-17-18:12:
Mobile WebKit can render ASCII through a later text fallback while xterm's canvas/DOM cell-measurement probe still binds metrics from the first listed symbols-only face. Keep real monospace text faces first for stable cell widths across mobile DOM/canvas and desktop WebGL renderers, then use the unicode-range-scoped symbols face as a fallback for powerline/Nerd-Font codepoints in every preset.
*/
export const XTERM_FONT_FAMILY = export const XTERM_FONT_FAMILY =
'"Fusion Terminal Nerd Font Symbols", "MesloLGS NF", "MesloLGM Nerd Font", "JetBrainsMono Nerd Font", "FiraCode Nerd Font", "Hack Nerd Font", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'; `"MesloLGS NF", "MesloLGM Nerd Font", "JetBrainsMono Nerd Font", "FiraCode Nerd Font", "Hack Nerd Font", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace, ${TERMINAL_SYMBOLS_FONT_FAMILY}`;
export const TERMINAL_FONT_FAMILY_PRESETS = [ export const TERMINAL_FONT_FAMILY_PRESETS = [
{ {
@@ -16,17 +22,17 @@ export const TERMINAL_FONT_FAMILY_PRESETS = [
{ {
id: "system-mono", id: "system-mono",
label: "System monospace", label: "System monospace",
css: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', css: `ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace, ${TERMINAL_SYMBOLS_FONT_FAMILY}`,
}, },
{ {
id: "jetbrains-mono", id: "jetbrains-mono",
label: "JetBrains Mono", label: "JetBrains Mono",
css: '"JetBrains Mono", "JetBrainsMono Nerd Font", ui-monospace, SFMono-Regular, monospace', css: `"JetBrains Mono", "JetBrainsMono Nerd Font", ui-monospace, SFMono-Regular, monospace, ${TERMINAL_SYMBOLS_FONT_FAMILY}`,
}, },
{ {
id: "fira-code", id: "fira-code",
label: "Fira Code", label: "Fira Code",
css: '"Fira Code", "FiraCode Nerd Font", ui-monospace, SFMono-Regular, monospace', css: `"Fira Code", "FiraCode Nerd Font", ui-monospace, SFMono-Regular, monospace, ${TERMINAL_SYMBOLS_FONT_FAMILY}`,
}, },
] as const; ] as const;