diff --git a/.changeset/fn-6659-terminal-render.md b/.changeset/fn-6659-terminal-render.md new file mode 100644 index 0000000000..91a974f59a --- /dev/null +++ b/.changeset/fn-6659-terminal-render.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix mobile terminal font measurement by keeping the symbols-only Nerd Font out of xterm's measured ASCII font stack while retaining a scoped DOM glyph 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 b98a0d718a..4d8a31ed21 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_or_symbols_first_stack_participated_in_ascii_cell_measurement +root_cause: symbols_only_font_face_participated_in_ios_xterm_ascii_cell_measurement_even_when_unicode_range_scoped resolution_type: code_fix severity: high related_components: @@ -21,6 +21,8 @@ related_components: - FN-6390 - FN-6424 - FN-6603 + - FN-6638 + - FN-6659 tags: - xterm - font-loading @@ -34,16 +36,19 @@ tags: ## Problem -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 participates 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. +FN-6638 then added a `text-size-adjust: 100%` pin plus best-effort `document.fonts` settlement and unconditional xterm option reapply/fit/refresh. That recurrence's diagnostic measured `66.76px for AGENTS.md` across symbols-first, symbols-last, and system-mono stacks and was initially read as "font-stack ordering is inert." FN-6659 corrected that reading: all three diagnostic stacks were still symbols-inclusive because every preset appended `"Fusion Terminal Nerd Font Symbols"`, and that symbols face was the only bundled/loaded terminal `@font-face`. Playwright/desktop WebKit emulation and the unfinished real-iOS acceptance gate let four blind fixes ship despite the real iOS Safari symptom remaining. + ## Solution -Keep the symbols font available for powerline/Nerd-Font codepoints, but apply both guards: +Keep the symbols font available for powerline/Nerd-Font codepoints, but do not let it participate in xterm's measured `fontFamily` option: 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. +2. Keep `XTERM_FONT_FAMILY` and every terminal preset symbols-free. `TerminalModal` and `SessionTerminal` must pass only real text monospace stacks to `new Terminal(...)`, remeasure, and live-preference updates. +3. If a DOM-renderer symbols fallback is needed, attach it through a separate scoped CSS variable/rule for `.xterm-rows span` (for example `--terminal-glyph-font-family`) rather than the xterm option that drives ASCII cell measurement. Do not re-tune ordering: FN-6659 showed symbols-last was still unsafe on real iOS because the symbols face's mere presence polluted the measured shorthand. Use the standard Symbols Nerd Font ranges, including powerline and private-use blocks, for example: @@ -56,7 +61,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 and stack ordering together 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 measured xterm font stack must stay symbols-free so symbols-only metrics cannot apply to ASCII on real iOS Safari. ## Regression coverage @@ -65,6 +70,7 @@ 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`). -- 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. +- Assert the shared default stack and every terminal font preset do **not** include `"Fusion Terminal Nerd Font Symbols"` in the xterm-measured family. +- Assert the retained symbols-rendering mechanism is separate from xterm measurement (for example CSS rules using `--terminal-glyph-font-family` on DOM row spans). +- Check every xterm consumer: `TerminalModal` and `SessionTerminal` both use `resolveTerminalFontFamily()`, so both need component-level coverage that the stack passed to `new Terminal(...)`, remeasure, and live preference updates is symbols-free. +- Verify on a real iOS Safari device/cloud path (not Playwright/desktop WebKit emulation) that ASCII output renders tightly while the powerline glyph still renders for the default `nerd-font` and `system-mono` presets on both `TerminalModal` and `SessionTerminal`. diff --git a/packages/dashboard/app/__tests__/terminal-input.test.ts b/packages/dashboard/app/__tests__/terminal-input.test.ts index 07d4e4ec69..98b77b3bc6 100644 --- a/packages/dashboard/app/__tests__/terminal-input.test.ts +++ b/packages/dashboard/app/__tests__/terminal-input.test.ts @@ -26,6 +26,18 @@ function findSessionTerminalTextSizingRule(): string { return match?.[1] ?? ""; } +function findTerminalGlyphFallbackRule(): string { + const match = css.match(/\.terminal-xterm\s+\.xterm-rows\s+span\s*\{([^}]*)\}/); + return match?.[1] ?? ""; +} + +function findSessionTerminalGlyphFallbackRule(): string { + const match = css.match( + /\.cli-session-terminal__viewport\s+\.xterm-rows\s+span\s*\{([^}]*)\}/, + ); + return match?.[1] ?? ""; +} + function expectTextSizeAdjustPinned(ruleBody: string): void { expect(ruleBody).not.toBe(""); expect(ruleBody).toMatch(/-webkit-text-size-adjust\s*:\s*100%\s*;/); @@ -103,6 +115,11 @@ describe("terminal helper textarea CSS contract", () => { it("pins iOS text-size adjustment on the SessionTerminal xterm viewport", () => { expectTextSizeAdjustPinned(findSessionTerminalTextSizingRule()); }); + + it("keeps a DOM glyph fallback mechanism outside xterm measurement options", () => { + expect(findTerminalGlyphFallbackRule()).toMatch(/--terminal-glyph-font-family/); + expect(findSessionTerminalGlyphFallbackRule()).toMatch(/--terminal-glyph-font-family/); + }); }); describe("FN-6424 terminal symbols font CSS contract", () => { @@ -118,7 +135,7 @@ describe("FN-6424 terminal symbols font CSS contract", () => { }); }); -describe("FN-6603 terminal font stack measurement contract", () => { +describe("FN-6659 terminal font stack measurement contract", () => { const symbolsFamily = '"Fusion Terminal Nerd Font Symbols"'; function splitFontFamilies(stack: string): string[] { @@ -128,28 +145,19 @@ describe("FN-6603 terminal font stack measurement contract", () => { .filter(Boolean); } - it("keeps the default symbols fallback after real monospace text fonts", () => { + it("keeps the default xterm measurement family free of the symbols face", () => { 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); + expect(families).not.toContain(symbolsFamily); + expect(families.length).toBeGreaterThan(0); }); - it("gives every terminal font preset a measurement-safe text face before symbols", () => { + it("keeps every terminal preset free of the symbols face xterm measures", () => { 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, - ); - } + expect(families, `${preset.id} xterm measurement stack`).not.toContain(symbolsFamily); + expect(families.length, `${preset.id} has a text font`).toBeGreaterThan(0); } }); }); diff --git a/packages/dashboard/app/components/SessionTerminal.css b/packages/dashboard/app/components/SessionTerminal.css index 5be9f21275..eb4253030f 100644 --- a/packages/dashboard/app/components/SessionTerminal.css +++ b/packages/dashboard/app/components/SessionTerminal.css @@ -132,6 +132,14 @@ SessionTerminal hosts the same xterm DOM/canvas measurement subtree as TerminalM text-size-adjust: 100%; } +/* +FNXC:Terminal 2026-06-18-15:45: +Recurrence #5 requires the attach terminal to mirror TerminalModal: xterm receives a symbols-free measured family, and only DOM row spans receive the optional glyph fallback. This keeps the real-iOS WebKit measurement invariant shared across DOM/canvas fallback and desktop WebGL while avoiding a SessionTerminal-only recurrence. +*/ +.cli-session-terminal__viewport .xterm-rows span { + font-family: var(--terminal-glyph-font-family, inherit) !important; +} + .cli-session-terminal__advance-strip { display: flex; align-items: center; diff --git a/packages/dashboard/app/components/SessionTerminal.tsx b/packages/dashboard/app/components/SessionTerminal.tsx index 0e78e5db58..de37b1fc4f 100644 --- a/packages/dashboard/app/components/SessionTerminal.tsx +++ b/packages/dashboard/app/components/SessionTerminal.tsx @@ -1,6 +1,6 @@ import "./SessionTerminal.css"; import "@xterm/xterm/css/xterm.css"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { useTranslation } from "react-i18next"; import { Terminal as TerminalIcon, ShieldAlert, Settings, Eye } from "lucide-react"; import type { Terminal as XTerm, ITerminalAddon } from "@xterm/xterm"; @@ -12,6 +12,7 @@ import { TERMINAL_PREFERENCES_KEY, readTerminalPreferences, resolveTerminalFontFamily, + resolveTerminalGlyphFontFamily, waitForTerminalFontMetrics, } from "../utils/terminalPreferences"; @@ -263,6 +264,10 @@ export function SessionTerminal({ const terminalPreferences = readTerminalPreferences(); terminal.options.fontFamily = resolveTerminalFontFamily(terminalPreferences.fontFamily); + containerRef.current?.style.setProperty( + "--terminal-glyph-font-family", + resolveTerminalGlyphFontFamily(terminalPreferences.fontFamily), + ); terminal.options.fontSize = terminalPreferences.fontSize; terminal.options.cursorStyle = terminalPreferences.cursorStyle; terminal.options.cursorBlink = terminalPreferences.cursorBlink && !readOnly && mode === "live"; @@ -342,10 +347,14 @@ export function SessionTerminal({ const terminalPreferences = readTerminalPreferences(); const resolvedFontFamily = resolveTerminalFontFamily(terminalPreferences.fontFamily); + containerRef.current.style.setProperty( + "--terminal-glyph-font-family", + resolveTerminalGlyphFontFamily(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-18-15:42: + SessionTerminal shares TerminalModal's recurrence #5 root cause: FN-6638's 66.76px diagnostic compared only symbols-inclusive stacks, so real iOS Safari still let the loaded symbols @font-face pollute xterm's ASCII measurement. Pass only the symbols-free resolved family to xterm on this attach surface too; DOM glyph fallback is scoped to the viewport CSS variable and never to the xterm font option used by DOM/canvas measurement or desktop WebGL. 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. @@ -546,6 +555,11 @@ export function SessionTerminal({ const elevated = Boolean(posture?.elevated); const flagSummary = posture?.elevatedFlags?.join(", "); + const terminalGlyphStyle = { + "--terminal-glyph-font-family": resolveTerminalGlyphFontFamily( + readTerminalPreferences().fontFamily, + ), + } as CSSProperties; return (
{showConfirmAdvance && !advanceDismissed && ( diff --git a/packages/dashboard/app/components/TerminalModal.css b/packages/dashboard/app/components/TerminalModal.css index 04dab42dd6..c1114f9ddf 100644 --- a/packages/dashboard/app/components/TerminalModal.css +++ b/packages/dashboard/app/components/TerminalModal.css @@ -555,6 +555,14 @@ Real iOS Safari recurrence #4 kept wide ASCII cells even after unicode-range sco text-size-adjust: 100%; } +/* +FNXC:Terminal 2026-06-18-15:44: +FN-6659 keeps the loaded symbols @font-face out of xterm's measured font option because every FN-6638 diagnostic stack that measured 66.76px still included that face. Limit the symbols fallback to DOM renderer row spans via a CSS custom property so ASCII measurement, fit, refresh, and WebGL/canvas option paths stay symbols-free while powerline glyphs can still resolve when xterm emits DOM text nodes. +*/ +.terminal-xterm .xterm-rows span { + font-family: var(--terminal-glyph-font-family, inherit) !important; +} + /* * 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 diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index f5532ee13f..fb1a3516cf 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -1,5 +1,5 @@ import "./TerminalModal.css"; -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect, useRef, useCallback, type CSSProperties } from "react"; import { useTranslation } from "react-i18next"; import { getErrorMessage } from "@fusion/core"; import { @@ -24,6 +24,7 @@ import { clampTerminalFontSize, readTerminalPreferences, resolveTerminalFontFamily, + resolveTerminalGlyphFontFamily, waitForTerminalFontMetrics, writeTerminalPreferences, type TerminalPreferences, @@ -243,6 +244,15 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG ); const fontSize = terminalPreferences.fontSize; const resolvedFontFamily = resolveTerminalFontFamily(terminalPreferences.fontFamily); + /* + FNXC:Terminal 2026-06-18-15:40: + TerminalModal must pass a symbols-free family to xterm so iOS WebKit measures ASCII cells against real monospace metrics. Keep the symbols fallback only in a scoped DOM glyph CSS variable; this preserves powerline glyph availability for DOM rows without reintroducing the loaded symbols @font-face into xterm's measurement, fit, or WebGL/canvas option path. + */ + const terminalGlyphStyle = { + "--terminal-glyph-font-family": resolveTerminalGlyphFontFamily( + terminalPreferences.fontFamily, + ), + } as CSSProperties; const [showShortcuts, setShowShortcuts] = useState(false); const [showPreferences, setShowPreferences] = useState(false); const [stickyModifier, setStickyModifier] = useState