diff --git a/.changeset/fn-6603-terminal-render.md b/.changeset/fn-6603-terminal-render.md new file mode 100644 index 0000000000..3f742ede46 --- /dev/null +++ b/.changeset/fn-6603-terminal-render.md @@ -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. diff --git a/docs/solutions/ui-bugs/xterm-symbols-nerd-font-unicode-range.md b/docs/solutions/ui-bugs/xterm-symbols-nerd-font-unicode-range.md index b494286a28..b98a0d718a 100644 --- a/docs/solutions/ui-bugs/xterm-symbols-nerd-font-unicode-range.md +++ b/docs/solutions/ui-bugs/xterm-symbols-nerd-font-unicode-range.md @@ -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. diff --git a/packages/dashboard/app/__tests__/terminal-input.test.ts b/packages/dashboard/app/__tests__/terminal-input.test.ts index a1def616b0..0b37c4c9bd 100644 --- a/packages/dashboard/app/__tests__/terminal-input.test.ts +++ b/packages/dashboard/app/__tests__/terminal-input.test.ts @@ -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, + ); + } + } + }); +}); diff --git a/packages/dashboard/app/components/SessionTerminal.tsx b/packages/dashboard/app/components/SessionTerminal.tsx index 43e624ea24..16ea32a556 100644 --- a/packages/dashboard/app/components/SessionTerminal.tsx +++ b/packages/dashboard/app/components/SessionTerminal.tsx @@ -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. */ diff --git a/packages/dashboard/app/components/TerminalModal.css b/packages/dashboard/app/components/TerminalModal.css index 74578e7361..1a6ace21c7 100644 --- a/packages/dashboard/app/components/TerminalModal.css +++ b/packages/dashboard/app/components/TerminalModal.css @@ -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"; diff --git a/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx b/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx index 1e5ca798af..56d1944df7 100644 --- a/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx @@ -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 () => { diff --git a/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx b/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx index 96a8806f58..7ff9e12252 100644 --- a/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx @@ -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, diff --git a/packages/dashboard/app/utils/terminalPreferences.ts b/packages/dashboard/app/utils/terminalPreferences.ts index feb40d1a7c..c473abe3a0 100644 --- a/packages/dashboard/app/utils/terminalPreferences.ts +++ b/packages/dashboard/app/utils/terminalPreferences.ts @@ -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;