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
This commit is contained in:
gsxdsm
2026-06-20 21:02:51 -07:00
parent 5d9162e138
commit c766bc9c95
7 changed files with 127 additions and 18 deletions

View File

@@ -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.

View File

@@ -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);
}
});
});

View File

@@ -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;

View File

@@ -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";

View File

@@ -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();
});

View File

@@ -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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
@@ -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(

View File

@@ -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 {