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
326 lines
10 KiB
TypeScript
326 lines
10 KiB
TypeScript
export const TERMINAL_PREFERENCES_KEY = "kb-terminal-preferences";
|
|
export const LEGACY_TERMINAL_FONT_SIZE_KEY = "kb-terminal-font-size";
|
|
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-18-15:38:
|
|
FN-6659 recurrence #5 showed the FN-6638 66.76px diagnostic compared only symbols-inclusive stacks: symbols-first, symbols-last, and system-mono all still contained the loaded unicode-range symbols @font-face. Real iOS Safari therefore implicated the symbols face's mere presence in xterm's measured font shorthand, not stack order. Keep xterm's measured family symbols-free for every preset and both terminal surfaces; a separate DOM glyph CSS layer may append the symbols face where it does not feed xterm's ASCII cell measurement.
|
|
*/
|
|
export const XTERM_FONT_FAMILY =
|
|
'"MesloLGS NF", "MesloLGM Nerd Font", "JetBrainsMono Nerd Font", "FiraCode Nerd Font", "Hack Nerd Font", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace';
|
|
|
|
export const TERMINAL_FONT_FAMILY_PRESETS = [
|
|
{
|
|
id: "nerd-font",
|
|
label: "Nerd Font stack",
|
|
css: XTERM_FONT_FAMILY,
|
|
},
|
|
{
|
|
id: "system-mono",
|
|
label: "System monospace",
|
|
css: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
|
},
|
|
{
|
|
id: "jetbrains-mono",
|
|
label: "JetBrains Mono",
|
|
css: '"JetBrains Mono", "JetBrainsMono Nerd Font", ui-monospace, SFMono-Regular, monospace',
|
|
},
|
|
{
|
|
id: "fira-code",
|
|
label: "Fira Code",
|
|
css: '"Fira Code", "FiraCode Nerd Font", ui-monospace, SFMono-Regular, monospace',
|
|
},
|
|
] as const;
|
|
|
|
export type TerminalFontFamily = (typeof TERMINAL_FONT_FAMILY_PRESETS)[number]["id"];
|
|
export type TerminalCursorStyle = "block" | "underline" | "bar";
|
|
export type TerminalRenderer = "auto" | "canvas";
|
|
|
|
export interface TerminalPreferences {
|
|
fontFamily: TerminalFontFamily;
|
|
fontSize: number;
|
|
cursorStyle: TerminalCursorStyle;
|
|
cursorBlink: boolean;
|
|
renderer: TerminalRenderer;
|
|
}
|
|
|
|
/*
|
|
FNXC:Terminal 2026-06-16-23:35:
|
|
Terminal preferences are intentionally client-local: users can customize font, cursor, and renderer without introducing server settings schema. Reads must tolerate unavailable storage, corrupt JSON, unknown enum values, and legacy font-size data so opening the terminal never throws and always falls back to safe defaults.
|
|
*/
|
|
export const DEFAULT_TERMINAL_PREFERENCES: TerminalPreferences = {
|
|
fontFamily: "nerd-font",
|
|
fontSize: DEFAULT_TERMINAL_FONT_SIZE,
|
|
cursorStyle: "block",
|
|
cursorBlink: true,
|
|
renderer: "auto",
|
|
};
|
|
|
|
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 {
|
|
const presetStack =
|
|
TERMINAL_FONT_FAMILY_PRESETS.find((preset) => preset.id === fontFamily)?.css ??
|
|
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 {
|
|
return `${resolveTerminalFontFamily(fontFamily)}, ${TERMINAL_SYMBOLS_FONT_FAMILY}`;
|
|
}
|
|
|
|
const CSS_GENERIC_FONT_FAMILIES = new Set([
|
|
"serif",
|
|
"sans-serif",
|
|
"monospace",
|
|
"cursive",
|
|
"fantasy",
|
|
"system-ui",
|
|
"ui-serif",
|
|
"ui-sans-serif",
|
|
"ui-monospace",
|
|
"ui-rounded",
|
|
"emoji",
|
|
"math",
|
|
"fangsong",
|
|
]);
|
|
|
|
type TerminalFontFaceSet = {
|
|
load?: (font: string, text?: string) => PromiseLike<unknown>;
|
|
ready?: PromiseLike<unknown>;
|
|
};
|
|
|
|
export function splitTerminalFontFamilies(stack: string): string[] {
|
|
return stack
|
|
.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/)
|
|
.map((family) => family.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function normalizeFontFamilyName(family: string): string {
|
|
const trimmed = family.trim();
|
|
if (
|
|
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
) {
|
|
return trimmed.slice(1, -1).trim();
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function isLoadableConcreteFontFamily(family: string): boolean {
|
|
const normalized = normalizeFontFamilyName(family).toLowerCase();
|
|
return normalized !== "" && !CSS_GENERIC_FONT_FAMILIES.has(normalized);
|
|
}
|
|
|
|
function getDocumentFonts(): TerminalFontFaceSet | undefined {
|
|
if (typeof document === "undefined") {
|
|
return undefined;
|
|
}
|
|
return document.fonts;
|
|
}
|
|
|
|
async function settleFontLoad(fonts: TerminalFontFaceSet, font: string): Promise<boolean> {
|
|
if (!fonts.load) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
await fonts.load(font);
|
|
return true;
|
|
} catch {
|
|
// Best-effort: strict iOS FontFaceSet parsing can reject one shorthand while
|
|
// later declarations or fonts.ready still give xterm a safe remeasure point.
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function waitForTerminalFontMetrics(
|
|
fontSize: number,
|
|
fontFamily: string,
|
|
fonts: TerminalFontFaceSet | undefined = getDocumentFonts(),
|
|
): Promise<boolean> {
|
|
if (!fonts?.load) {
|
|
return false;
|
|
}
|
|
|
|
const fontSizeCss = `${fontSize}px`;
|
|
const declarations = [
|
|
`${fontSizeCss} ${fontFamily}`,
|
|
...splitTerminalFontFamilies(fontFamily)
|
|
.filter(isLoadableConcreteFontFamily)
|
|
.map((family) => `${fontSizeCss} ${family}`),
|
|
];
|
|
|
|
/*
|
|
FNXC:Terminal 2026-06-18-07:02:
|
|
FN-6638 recurrence #4 showed font-stack order was inert: the supplied diagnostic measured AGENTS.md at the same 66.76px with symbols-first, symbols-last, and system-mono stacks while real iOS Safari still rendered wide ASCII cells. Treat FontFaceSet loading as best-effort and always leave callers free to reapply xterm font options; strict iOS WebKit can reject the long multi-family shorthand, and that rejection must not suppress DOM/canvas or WebGL metric invalidation for any preset.
|
|
*/
|
|
const [fullStackDeclaration, ...individualDeclarations] = declarations;
|
|
const fullStackLoaded = fullStackDeclaration
|
|
? await settleFontLoad(fonts, fullStackDeclaration)
|
|
: false;
|
|
|
|
if (!fullStackLoaded) {
|
|
for (const declaration of individualDeclarations) {
|
|
await settleFontLoad(fonts, declaration);
|
|
}
|
|
}
|
|
|
|
try {
|
|
await fonts.ready;
|
|
} catch {
|
|
// Continue to xterm remeasure even if the FontFaceSet settles rejected.
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function isObject(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function isTerminalFontFamily(value: unknown): value is TerminalFontFamily {
|
|
return (
|
|
typeof value === "string" &&
|
|
TERMINAL_FONT_FAMILY_PRESETS.some((preset) => preset.id === value)
|
|
);
|
|
}
|
|
|
|
function isTerminalCursorStyle(value: unknown): value is TerminalCursorStyle {
|
|
return value === "block" || value === "underline" || value === "bar";
|
|
}
|
|
|
|
function isTerminalRenderer(value: unknown): value is TerminalRenderer {
|
|
return value === "auto" || value === "canvas";
|
|
}
|
|
|
|
function readLegacyFontSize(): number | undefined {
|
|
if (typeof window === "undefined") {
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
const savedFontSize = window.localStorage?.getItem?.(LEGACY_TERMINAL_FONT_SIZE_KEY);
|
|
if (!savedFontSize) {
|
|
return undefined;
|
|
}
|
|
|
|
const parsed = Number.parseInt(savedFontSize, 10);
|
|
if (!Number.isFinite(parsed)) {
|
|
return undefined;
|
|
}
|
|
|
|
return clampTerminalFontSize(parsed);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function normalizeTerminalPreferences(value: unknown): TerminalPreferences {
|
|
const source = isObject(value) ? value : {};
|
|
const rawFontSize = source.fontSize;
|
|
const parsedFontSize =
|
|
typeof rawFontSize === "number"
|
|
? rawFontSize
|
|
: typeof rawFontSize === "string"
|
|
? Number.parseInt(rawFontSize, 10)
|
|
: Number.NaN;
|
|
|
|
return {
|
|
fontFamily: isTerminalFontFamily(source.fontFamily)
|
|
? source.fontFamily
|
|
: DEFAULT_TERMINAL_PREFERENCES.fontFamily,
|
|
fontSize: Number.isFinite(parsedFontSize)
|
|
? clampTerminalFontSize(parsedFontSize)
|
|
: DEFAULT_TERMINAL_PREFERENCES.fontSize,
|
|
cursorStyle: isTerminalCursorStyle(source.cursorStyle)
|
|
? source.cursorStyle
|
|
: DEFAULT_TERMINAL_PREFERENCES.cursorStyle,
|
|
cursorBlink:
|
|
typeof source.cursorBlink === "boolean"
|
|
? source.cursorBlink
|
|
: DEFAULT_TERMINAL_PREFERENCES.cursorBlink,
|
|
renderer: isTerminalRenderer(source.renderer)
|
|
? source.renderer
|
|
: DEFAULT_TERMINAL_PREFERENCES.renderer,
|
|
};
|
|
}
|
|
|
|
export function readTerminalPreferences(): TerminalPreferences {
|
|
if (typeof window === "undefined") {
|
|
return { ...DEFAULT_TERMINAL_PREFERENCES };
|
|
}
|
|
|
|
try {
|
|
const savedPreferences = window.localStorage?.getItem?.(TERMINAL_PREFERENCES_KEY);
|
|
if (savedPreferences) {
|
|
return normalizeTerminalPreferences(JSON.parse(savedPreferences));
|
|
}
|
|
|
|
const legacyFontSize = readLegacyFontSize();
|
|
if (legacyFontSize === undefined) {
|
|
return { ...DEFAULT_TERMINAL_PREFERENCES };
|
|
}
|
|
|
|
const migratedPreferences = {
|
|
...DEFAULT_TERMINAL_PREFERENCES,
|
|
fontSize: legacyFontSize,
|
|
};
|
|
window.localStorage?.setItem?.(
|
|
TERMINAL_PREFERENCES_KEY,
|
|
JSON.stringify(migratedPreferences),
|
|
);
|
|
return migratedPreferences;
|
|
} catch {
|
|
return { ...DEFAULT_TERMINAL_PREFERENCES };
|
|
}
|
|
}
|
|
|
|
export function writeTerminalPreferences(
|
|
patch: Partial<TerminalPreferences>,
|
|
): TerminalPreferences {
|
|
const nextPreferences = normalizeTerminalPreferences({
|
|
...readTerminalPreferences(),
|
|
...patch,
|
|
});
|
|
|
|
if (typeof window === "undefined") {
|
|
return nextPreferences;
|
|
}
|
|
|
|
try {
|
|
window.localStorage?.setItem?.(
|
|
TERMINAL_PREFERENCES_KEY,
|
|
JSON.stringify(nextPreferences),
|
|
);
|
|
// Keep the retired scalar value in sync for any stale tab still reading it
|
|
// while this deployment is hot-reloaded.
|
|
window.localStorage?.setItem?.(
|
|
LEGACY_TERMINAL_FONT_SIZE_KEY,
|
|
String(nextPreferences.fontSize),
|
|
);
|
|
} catch {
|
|
// Ignore persistence failures; callers still receive the normalized live value.
|
|
}
|
|
|
|
return nextPreferences;
|
|
}
|