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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user