From c766bc9c95855dff081748e8e928338feb74a99e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 20 Jun 2026 21:02:51 -0700 Subject: [PATCH] FN-6811: keep terminal symbols out of xterm metrics Keep terminal symbol fonts scoped away from xterm's measured text stacks across modal and session surfaces. - add a scoped symbols @font-face to the session terminal CSS chunk - defensively strip the symbols font from resolved xterm font-family presets - expand modal, session, and CSS regression tests for symbols-free measurement and scoped glyph fallback - document recurrence #6 and the remaining real-device iOS Safari verification gap Files changed: .../xterm-symbols-nerd-font-unicode-range.md | 13 ++++-- .../dashboard/app/__tests__/terminal-input.test.ts | 51 ++++++++++++++++++---- .../dashboard/app/components/SessionTerminal.css | 11 +++++ .../dashboard/app/components/TerminalModal.css | 3 ++ .../components/__tests__/SessionTerminal.test.tsx | 3 ++ .../components/__tests__/TerminalModal.test.tsx | 47 ++++++++++++++++++-- .../dashboard/app/utils/terminalPreferences.ts | 17 ++++++-- 7 files changed, 127 insertions(+), 18 deletions(-) Fusion-Task-Id: FN-6811 Fusion-Task-Lineage: 15a6fc1f-34c6-44d4-b9ea-8b5b3baae267 --- .../xterm-symbols-nerd-font-unicode-range.md | 13 +++-- .../app/__tests__/terminal-input.test.ts | 51 ++++++++++++++++--- .../app/components/SessionTerminal.css | 11 ++++ .../app/components/TerminalModal.css | 3 ++ .../__tests__/SessionTerminal.test.tsx | 3 ++ .../__tests__/TerminalModal.test.tsx | 47 +++++++++++++++-- .../app/utils/terminalPreferences.ts | 17 +++++-- 7 files changed, 127 insertions(+), 18 deletions(-) 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 4d8a31ed21..33d69b599a 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 @@ -15,6 +15,7 @@ resolution_type: code_fix severity: high related_components: - packages/dashboard/app/components/TerminalModal.css + - packages/dashboard/app/components/SessionTerminal.css - packages/dashboard/app/components/TerminalModal.tsx - packages/dashboard/app/components/SessionTerminal.tsx - packages/dashboard/app/__tests__/terminal-input.test.ts @@ -23,6 +24,7 @@ related_components: - FN-6603 - FN-6638 - FN-6659 + - FN-6811 tags: - xterm - font-loading @@ -42,13 +44,17 @@ FN-6603 found the third recurrence: the FN-6390 remeasure and FN-6424 `unicode-r 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. +FN-6811 found recurrence #6 in the attach/session terminal surface. `SessionTerminal` is code-split with its own `SessionTerminal.css`, so it could render without the scoped symbols `@font-face` owned by `TerminalModal.css`; tests mostly inspected combined CSS and did not prove each xterm surface owned the ASCII-excluding symbols face. The fix duplicated the scoped `@font-face` into `SessionTerminal.css`, made `resolveTerminalFontFamily()` defensively strip the symbols face from any xterm-measured stack, and added per-surface tests for modal and session terminals. Real-device iOS Safari verification remains an explicit gap for this recurrence; until it is run, rely only on the automated contract checks plus a documented manual/cloud-device pass before claiming the mobile symptom is closed. + ## Solution 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 `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. +2. Keep `XTERM_FONT_FAMILY` and every terminal preset symbols-free. `resolveTerminalFontFamily()` should also defensively strip `"Fusion Terminal Nerd Font Symbols"` so a future preset edit cannot feed the symbols-only face into xterm measurement. +3. `TerminalModal` and `SessionTerminal` must pass only real text monospace stacks to `new Terminal(...)`, remeasure, and live-preference updates. +4. 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. +5. Each code-split terminal CSS owner that exposes the DOM glyph fallback must define or import the scoped symbols face in that chunk. FN-6811 showed relying on `TerminalModal.css` alone leaks when `SessionTerminal.css` is loaded independently. Use the standard Symbols Nerd Font ranges, including powerline and private-use blocks, for example: @@ -73,4 +79,5 @@ Automated jsdom tests cannot validate font advance widths, so cover the enforcea - 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`. +- Check each code-split CSS owner independently (`TerminalModal.css` and `SessionTerminal.css`) for the scoped symbols `@font-face`; do not rely only on a combined app stylesheet scan. +- 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`. If the real-device pass is unavailable, record that as an explicit gap in the task/review notes rather than treating desktop WebKit or jsdom as proof. diff --git a/packages/dashboard/app/__tests__/terminal-input.test.ts b/packages/dashboard/app/__tests__/terminal-input.test.ts index 98b77b3bc6..666986da52 100644 --- a/packages/dashboard/app/__tests__/terminal-input.test.ts +++ b/packages/dashboard/app/__tests__/terminal-input.test.ts @@ -1,11 +1,23 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { describe, it, expect } from "vitest"; import { loadAllAppCss } from "../test/cssFixture"; import { TERMINAL_FONT_FAMILY_PRESETS, + TERMINAL_SYMBOLS_FONT_FAMILY, XTERM_FONT_FAMILY, + resolveTerminalFontFamily, } from "../utils/terminalPreferences"; const css = loadAllAppCss(); +const terminalModalCss = readFileSync( + resolve(__dirname, "../components/TerminalModal.css"), + "utf8", +); +const sessionTerminalCss = readFileSync( + resolve(__dirname, "../components/SessionTerminal.css"), + "utf8", +); function findHelperTextareaRule(): string { const match = css.match( @@ -44,8 +56,8 @@ function expectTextSizeAdjustPinned(ruleBody: string): void { expect(ruleBody).toMatch(/text-size-adjust\s*:\s*100%\s*;/); } -function findTerminalSymbolsFontFaceRule(): string { - const fontFaceRules = css.match(/@font-face\s*\{[^}]*\}/g) ?? []; +function findTerminalSymbolsFontFaceRule(cssSource = css): string { + const fontFaceRules = cssSource.match(/@font-face\s*\{[^}]*\}/g) ?? []; return ( fontFaceRules.find((rule) => /font-family\s*:\s*["']Fusion Terminal Nerd Font Symbols["']/.test(rule), @@ -119,24 +131,38 @@ describe("terminal helper textarea CSS contract", () => { 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/); + expect(findTerminalGlyphFallbackRule()).not.toMatch(/Fusion Terminal Nerd Font Symbols/); + expect(findSessionTerminalGlyphFallbackRule()).not.toMatch(/Fusion Terminal Nerd Font Symbols/); }); }); describe("FN-6424 terminal symbols font CSS contract", () => { - it("scopes the symbols-only Nerd Font away from ASCII cell measurement", () => { - const ruleBody = findTerminalSymbolsFontFaceRule(); - expect(ruleBody).not.toBe(""); + function expectScopedSymbolsFontFace(cssSource: string, surface: string): void { + const ruleBody = findTerminalSymbolsFontFaceRule(cssSource); + expect(ruleBody, `${surface} symbols @font-face`).not.toBe(""); const unicodeRanges = parseUnicodeRangeValues(ruleBody); - expect(unicodeRanges).toEqual( + expect(unicodeRanges, `${surface} required Nerd Font ranges`).toEqual( expect.arrayContaining(["U+E0A0-E0D7", "U+E700-E8EF", "U+F0001-F1AF0"]), ); - expect(unicodeRanges.some(unicodeRangeIncludesAsciiPrintable)).toBe(false); + expect( + unicodeRanges.some(unicodeRangeIncludesAsciiPrintable), + `${surface} symbols @font-face excludes printable ASCII`, + ).toBe(false); + } + + it("scopes the symbols-only Nerd Font away from ASCII cell measurement", () => { + expectScopedSymbolsFontFace(css, "combined app CSS"); + }); + + it("keeps the scoped symbols face owned by each terminal surface CSS chunk", () => { + expectScopedSymbolsFontFace(terminalModalCss, "TerminalModal.css"); + expectScopedSymbolsFontFace(sessionTerminalCss, "SessionTerminal.css"); }); }); describe("FN-6659 terminal font stack measurement contract", () => { - const symbolsFamily = '"Fusion Terminal Nerd Font Symbols"'; + const symbolsFamily = TERMINAL_SYMBOLS_FONT_FAMILY; function splitFontFamilies(stack: string): string[] { return stack @@ -160,4 +186,13 @@ describe("FN-6659 terminal font stack measurement contract", () => { expect(families.length, `${preset.id} has a text font`).toBeGreaterThan(0); } }); + + it("resolves every xterm-measured preset without the DOM-only symbols face", () => { + for (const preset of TERMINAL_FONT_FAMILY_PRESETS) { + const families = splitFontFamilies(resolveTerminalFontFamily(preset.id)); + + expect(families, `${preset.id} resolved xterm stack`).not.toContain(symbolsFamily); + expect(families.length, `${preset.id} resolved text font`).toBeGreaterThan(0); + } + }); }); diff --git a/packages/dashboard/app/components/SessionTerminal.css b/packages/dashboard/app/components/SessionTerminal.css index 70a4d32671..fcd16d4647 100644 --- a/packages/dashboard/app/components/SessionTerminal.css +++ b/packages/dashboard/app/components/SessionTerminal.css @@ -1,5 +1,16 @@ /* SessionTerminal (CLI Agent Executor, U11) — canonical tokens only. */ +/* +FNXC:Terminal 2026-06-20-18:04: +FN-6811 recurrence #6 found the attach terminal imported its own CSS chunk but did not own the scoped symbols @font-face. Keep SessionTerminal self-contained: the DOM glyph fallback may resolve powerline/Nerd-Font codepoints here, while xterm's measured fontFamily remains symbols-free for ASCII cell metrics. +*/ +@font-face { + font-family: "Fusion Terminal Nerd Font Symbols"; + src: url("/fonts/SymbolsNerdFontMono-Regular.ttf") format("truetype"); + font-display: swap; + unicode-range: U+23FB-23FE, U+2665, U+26A1, U+2B58, U+E000-E00A, U+E0A0-E0D7, U+E200-E2A9, U+E300-E3E3, U+E5FA-E6B7, U+E700-E8EF, U+EA60-EC1E, U+ED00-F2FF, U+F300-F533, U+F0001-F1AF0; +} + .cli-session-terminal { display: flex; flex-direction: column; diff --git a/packages/dashboard/app/components/TerminalModal.css b/packages/dashboard/app/components/TerminalModal.css index 8d6080a47d..893ce20971 100644 --- a/packages/dashboard/app/components/TerminalModal.css +++ b/packages/dashboard/app/components/TerminalModal.css @@ -4,6 +4,9 @@ The symbols-only Nerd Font must stay unicode-range-scoped to Nerd Font codepoint 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. + +FNXC:Terminal 2026-06-20-18:04: +FN-6811 recurrence #6 tightened ownership of this scoped symbols face: every terminal surface that exposes the DOM glyph fallback must define the same ASCII-excluding @font-face in its own CSS chunk, and tests must check each CSS owner independently rather than relying on combined app CSS. */ @font-face { font-family: "Fusion Terminal Nerd Font Symbols"; diff --git a/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx b/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx index d3965d4117..5a5402edc4 100644 --- a/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx @@ -186,6 +186,7 @@ describe("SessionTerminal", () => { await waitFor(() => { expect(mockTerm.options.fontFamily).toBe(resolveTerminalFontFamily("nerd-font")); + expectMeasurementSafeFontStack(mockTerm.options.fontFamily as string); 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); @@ -216,6 +217,7 @@ describe("SessionTerminal", () => { cursorBlink: true, }), ); + expectMeasurementSafeFontStack(mockTerm.options.fontFamily as string); }); it("falls back to safe default preferences for corrupt storage", async () => { @@ -312,6 +314,7 @@ describe("SessionTerminal", () => { cursorBlink: false, }); }); + expectMeasurementSafeFontStack(mockTerm.options.fontFamily as string); expect(mockFitAddon.fit).toHaveBeenCalled(); }); diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx index 694bfef16a..4ac3d19897 100644 --- a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx @@ -9,12 +9,27 @@ import { DEFAULT_TERMINAL_PREFERENCES, LEGACY_TERMINAL_FONT_SIZE_KEY, TERMINAL_PREFERENCES_KEY, + TERMINAL_SYMBOLS_FONT_FAMILY, XTERM_FONT_FAMILY, + resolveTerminalFontFamily, } from "../../utils/terminalPreferences"; import * as useTerminalModule from "../../hooks/useTerminal"; import * as useTerminalSessionsModule from "../../hooks/useTerminalSessions"; import * as apiModule from "../../api"; +function splitFontFamilies(stack: string): string[] { + return stack + .split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/) + .map((family) => family.trim()) + .filter(Boolean); +} + +function expectMeasurementSafeFontStack(stack: string): void { + const families = splitFontFamilies(stack); + expect(families.length).toBeGreaterThan(0); + expect(families).not.toContain(TERMINAL_SYMBOLS_FONT_FAMILY); +} + // Mock hooks and API vi.mock("../../hooks/useTerminal", () => ({ useTerminal: vi.fn(), @@ -647,9 +662,34 @@ describe("TerminalModal", () => { fontFamily: XTERM_FONT_FAMILY, }), ); + expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string); expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("14px"); }); + it("initializes xterm with a non-default symbols-free font preset", async () => { + const { Terminal } = await import("@xterm/xterm"); + window.localStorage.setItem( + TERMINAL_PREFERENCES_KEY, + JSON.stringify({ + ...DEFAULT_TERMINAL_PREFERENCES, + fontFamily: "system-mono", + }), + ); + + render(); + + await waitFor(() => { + expect(mockTerminalInstance.open).toHaveBeenCalled(); + }); + + expect(Terminal).toHaveBeenCalledWith( + expect.objectContaining({ + fontFamily: resolveTerminalFontFamily("system-mono"), + }), + ); + expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string); + }); + describe("shortcut panel", () => { it("is hidden by default and toggles from header action", async () => { render(); @@ -1080,7 +1120,8 @@ describe("TerminalModal", () => { fireEvent.click(screen.getByTestId("terminal-preference-cursor-blink")); await waitFor(() => { - expect(mockTerminalInstance.options.fontFamily).toContain("ui-monospace"); + expect(mockTerminalInstance.options.fontFamily).toBe(resolveTerminalFontFamily("system-mono")); + expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string); expect(mockTerminalInstance.options.cursorStyle).toBe("underline"); expect(mockTerminalInstance.options.cursorBlink).toBe(false); }); @@ -5085,9 +5126,7 @@ describe("TerminalModal — xterm focus initialization (FN-1602)", () => { await waitFor(() => { expect(mockTerminalInstance.options.fontFamily).toBe(XTERM_FONT_FAMILY); - expect(mockTerminalInstance.options.fontFamily).not.toContain( - "Fusion Terminal Nerd Font Symbols", - ); + expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string); expect(mockTerminalInstance.options.fontSize).toBe(DEFAULT_TERMINAL_PREFERENCES.fontSize); expect(mockFitAddonFit.mock.calls.length).toBeGreaterThan(fitCallBaseline); expect(mockResize).toHaveBeenCalledWith( diff --git a/packages/dashboard/app/utils/terminalPreferences.ts b/packages/dashboard/app/utils/terminalPreferences.ts index a2a6b063a2..dc6c62b3f7 100644 --- a/packages/dashboard/app/utils/terminalPreferences.ts +++ b/packages/dashboard/app/utils/terminalPreferences.ts @@ -64,11 +64,22 @@ export function clampTerminalFontSize(value: number): number { return Math.min(MAX_TERMINAL_FONT_SIZE, Math.max(MIN_TERMINAL_FONT_SIZE, value)); } +function stripTerminalSymbolsFontFamily(stack: string): string { + return splitTerminalFontFamilies(stack) + .filter((family) => family !== TERMINAL_SYMBOLS_FONT_FAMILY) + .join(", "); +} + export function resolveTerminalFontFamily(fontFamily: TerminalFontFamily): string { - return ( + const presetStack = TERMINAL_FONT_FAMILY_PRESETS.find((preset) => preset.id === fontFamily)?.css ?? - XTERM_FONT_FAMILY - ); + XTERM_FONT_FAMILY; + + /* + FNXC:Terminal 2026-06-20-18:04: + FN-6811 recurrence #6 keeps the symbols-free measured-family invariant defensive at the shared resolver boundary. Preset constants and tests should stay clean, but this filter prevents any future UI path from accidentally feeding the symbols-only face into xterm's ASCII cell measurement option. + */ + return stripTerminalSymbolsFontFamily(presetStack); } export function resolveTerminalGlyphFontFamily(fontFamily: TerminalFontFamily): string {