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:
5
.changeset/fn-6603-terminal-render.md
Normal file
5
.changeset/fn-6603-terminal-render.md
Normal 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.
|
||||
@@ -10,7 +10,7 @@ symptoms:
|
||||
- "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"
|
||||
- "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
|
||||
severity: high
|
||||
related_components:
|
||||
@@ -20,6 +20,7 @@ related_components:
|
||||
- packages/dashboard/app/__tests__/terminal-input.test.ts
|
||||
- FN-6390
|
||||
- FN-6424
|
||||
- FN-6603
|
||||
tags:
|
||||
- xterm
|
||||
- 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.
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
- 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`).
|
||||
- Check sibling xterm surfaces: `SessionTerminal` is unaffected if it uses a system monospace stack and does not include the symbols font.
|
||||
- Verify in a mobile/touch browser path that ASCII output renders tightly while the powerline glyph still renders.
|
||||
- Assert the shared default stack and every terminal font preset place a real text monospace face before `"Fusion Terminal Nerd Font Symbols"`.
|
||||
- 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.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { loadAllAppCss } from "../test/cssFixture";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import {
|
||||
TERMINAL_FONT_FAMILY_PRESETS,
|
||||
XTERM_FONT_FAMILY,
|
||||
} from "../utils/terminalPreferences";
|
||||
|
||||
const css = loadAllAppCss();
|
||||
|
||||
@@ -89,3 +91,39 @@ describe("FN-6424 terminal symbols font CSS contract", () => {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -343,6 +343,9 @@ export function SessionTerminal({
|
||||
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:
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/*
|
||||
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-family: "Fusion Terminal Nerd Font Symbols";
|
||||
|
||||
@@ -93,7 +93,26 @@ function stubScreen(width: number, height: number) {
|
||||
}
|
||||
|
||||
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. */
|
||||
function inputFrames(ws: FakeWS): string[] {
|
||||
@@ -318,6 +337,7 @@ describe("SessionTerminal (mobile)", () => {
|
||||
await renderMobile();
|
||||
|
||||
expect(WebglAddon).not.toHaveBeenCalled();
|
||||
expectMeasurementSafeFontStack(mockTerm.options.fontFamily as string);
|
||||
});
|
||||
|
||||
it("keeps the accessory key bar intact while applying terminal preferences", async () => {
|
||||
|
||||
@@ -56,8 +56,25 @@ import { SessionTerminal } from "../SessionTerminal";
|
||||
import {
|
||||
DEFAULT_TERMINAL_PREFERENCES,
|
||||
TERMINAL_PREFERENCES_KEY,
|
||||
TERMINAL_SYMBOLS_FONT_FAMILY,
|
||||
resolveTerminalFontFamily,
|
||||
} 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(() => {
|
||||
FakeWS.instances = [];
|
||||
originalWebSocket = (globalThis as typeof globalThis & { WebSocket?: typeof WebSocket }).WebSocket;
|
||||
@@ -123,6 +140,7 @@ describe("SessionTerminal", () => {
|
||||
cursorBlink: DEFAULT_TERMINAL_PREFERENCES.cursorBlink,
|
||||
}),
|
||||
);
|
||||
expectMeasurementSafeFontStack(mockTerm.options.fontFamily as string);
|
||||
expect(mockTerm.attachCustomKeyEventHandler).not.toHaveBeenCalled();
|
||||
|
||||
const inputHandler = mockTerm.onData.mock.calls[0]?.[0] as
|
||||
@@ -154,7 +172,7 @@ describe("SessionTerminal", () => {
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
expect(Terminal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||
fontFamily: resolveTerminalFontFamily("system-mono"),
|
||||
fontSize: 18,
|
||||
cursorStyle: "underline",
|
||||
cursorBlink: true,
|
||||
@@ -249,8 +267,7 @@ describe("SessionTerminal", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTerm.options).toMatchObject({
|
||||
fontFamily:
|
||||
'"JetBrains Mono", "JetBrainsMono Nerd Font", ui-monospace, SFMono-Regular, monospace',
|
||||
fontFamily: resolveTerminalFontFamily("jetbrains-mono"),
|
||||
fontSize: 20,
|
||||
cursorStyle: "bar",
|
||||
cursorBlink: false,
|
||||
|
||||
@@ -4,8 +4,14 @@ export const DEFAULT_TERMINAL_FONT_SIZE = 14;
|
||||
export const MIN_TERMINAL_FONT_SIZE = 8;
|
||||
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 =
|
||||
'"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 = [
|
||||
{
|
||||
@@ -16,17 +22,17 @@ export const TERMINAL_FONT_FAMILY_PRESETS = [
|
||||
{
|
||||
id: "system-mono",
|
||||
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",
|
||||
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",
|
||||
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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user