FN-7460: disable WebKit text scaling in terminals

Disable WebKit terminal text scaling so iPhone Safari preserves exact xterm cell metrics.

- Set terminal xterm host subtrees to disable text-size adjustment instead of pinning it at 100%.
- Update terminal CSS contract coverage for modal and session terminal surfaces.
- Document the real-iPhone recurrence and add a patch changeset for the published CLI package.

Files changed:
 .changeset/fn-7460-ios-terminal-spacing.md                 |  7 +++++++
 .../ui-bugs/xterm-async-font-remeasure-paste-dedupe.md     |  7 ++++---
 packages/dashboard/app/__tests__/terminal-input.test.ts    | 14 +++++++-------
 packages/dashboard/app/components/SessionTerminal.css      |  7 +++++--
 packages/dashboard/app/components/TerminalModal.css        |  9 ++++++---
 .../app/components/__tests__/TerminalModal.test.tsx        |  7 +++++++
 6 files changed, 36 insertions(+), 15 deletions(-)

Fusion-Task-Id: FN-7460

Fusion-Task-Lineage: 00542017-457b-40b8-9139-084218bc3533

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-03 00:25:49 -07:00
parent 95325201a6
commit 7300bf54ce
6 changed files with 36 additions and 15 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix iPhone Safari terminal text spacing with the keyboard open.
category: fix
dev: Disables WebKit text-size adjustment inside dashboard xterm measurement subtrees.

View File

@@ -23,6 +23,7 @@ related_components:
- packages/dashboard/app/__tests__/terminal-input.test.ts
- FN-6390
- FN-6638
- FN-7460
tags:
- xterm
- font-loading
@@ -40,7 +41,7 @@ xterm.js measures character-cell geometry when `terminal.open()` runs. If a cust
FN-6638 was the fourth recurrence of the mobile wide-cell defect (FN-6390 → FN-6424 → FN-6603 → FN-6638). The FN-6603 font-stack ordering hypothesis was ruled out: the supplied diagnostic measured `66.76px for AGENTS.md` identically for symbols-first, symbols-last, and system-mono stacks, and desktop/mobile-emulated WebKit rendered ASCII tightly while a real iOS Safari screenshot still showed `A G E N T S . m d`. Treat Playwright/desktop WebKit emulation as a blind spot for this class; it can prove CSS contracts and fallback paths but cannot be the acceptance surface.
The recurrence path was stricter real-iOS font/text measurement behavior. A long `document.fonts.load(`${fontSize}px ${resolvedFontFamily}`)` shorthand can reject on iOS WebKit; returning from that catch prevented xterm from reapplying `fontFamily`/`fontSize`, running `fitAddon.fit()`, publishing resize, and refreshing rows. Separately, the xterm measurement subtree lacked `-webkit-text-size-adjust: 100%`, allowing iOS Safari text inflation to perturb cell metrics.
The recurrence path was stricter real-iOS font/text measurement behavior. A long `document.fonts.load(`${fontSize}px ${resolvedFontFamily}`)` shorthand can reject on iOS WebKit; returning from that catch prevented xterm from reapplying `fontFamily`/`fontSize`, running `fitAddon.fit()`, publishing resize, and refreshing rows. Separately, the xterm measurement subtree must disable WebKit text-size adjustment entirely. FN-7460 reopened the issue after the 100% pin still let a real iPhone Safari keyboard-open 12px terminal render prompt and ASCII segments with excessive inter-character spacing.
A second pitfall is custom paste handling. If an `attachCustomKeyEventHandler` Cmd/Ctrl+V branch reads `navigator.clipboard.readText()` and forwards that text to the PTY while the browser also performs the native paste into xterm's helper textarea, the same payload reaches `terminal.onData` and is sent twice.
@@ -53,7 +54,7 @@ Keep one canonical paste path and remeasure after font resolution.
- After `terminal.open()`, treat FontFaceSet loading as best-effort: try the full stack, fall back to concrete individual families only if the full shorthand rejects, await `document.fonts.ready`, and never let an iOS shorthand rejection skip the later remeasure.
- Guard async remeasure work with the expected session id and current terminal/addon refs so stale font-load promises cannot mutate a disposed or switched terminal.
- Reapply font options, run `fitAddon.fit()`, publish the resized cols/rows, and refresh visible rows once the FontFaceSet has settled.
- Pin `-webkit-text-size-adjust: 100%` / `text-size-adjust: 100%` on the xterm host subtree (`.terminal-xterm` and `.cli-session-terminal__viewport`) so iOS Safari cannot inflate DOM/canvas measurement nodes.
- Disable `-webkit-text-size-adjust` / `text-size-adjust` on the xterm host subtree (`.terminal-xterm` and `.cli-session-terminal__viewport`) so iOS Safari cannot inflate DOM/canvas measurement nodes while xterm still honors the user's exact 10px/12px terminal font preference.
`SessionTerminal` is unaffected by paste duplication because it does not install a custom paste handler; native xterm paste is its only input path. It is affected by the font/cell-measurement invariant because it constructs xterm with the same user-selectable font presets and mobile DOM/canvas renderer path, so it must share both the best-effort font-load remeasure and the text-size-adjust pin.
@@ -65,7 +66,7 @@ Cover the invariant across terminal surfaces and input paths:
- Native helper-textarea paste without the shortcut handler sends exactly once, covering mobile/iOS context-menu paste.
- A controlled `document.fonts.load()` promise resolving after `terminal.open()` triggers a post-font-load fit, resize, and refresh.
- A controlled `document.fonts.load()` rejection (the real-iOS shorthand failure mode) still triggers font option reapply, fit/resize, and refresh for both `TerminalModal` and `SessionTerminal`.
- CSS contract tests assert both xterm host subtrees pin `text-size-adjust` to 100%.
- CSS contract tests assert both xterm host subtrees disable `text-size-adjust` for exact xterm cell metrics.
- `SessionTerminal` asserts it uses the shared terminal font presets, does not attach a custom key handler, and sends one native xterm paste input frame.
This avoids downstream byte de-duplication and fixes the two root causes at their renderer/input seams.

View File

@@ -50,10 +50,10 @@ function findSessionTerminalGlyphFallbackRule(): string {
return match?.[1] ?? "";
}
function expectTextSizeAdjustPinned(ruleBody: string): void {
function expectTextSizeAdjustDisabledForExactXtermMetrics(ruleBody: string): void {
expect(ruleBody).not.toBe("");
expect(ruleBody).toMatch(/-webkit-text-size-adjust\s*:\s*100%\s*;/);
expect(ruleBody).toMatch(/text-size-adjust\s*:\s*100%\s*;/);
expect(ruleBody).toMatch(/-webkit-text-size-adjust\s*:\s*none\s*;/);
expect(ruleBody).toMatch(/text-size-adjust\s*:\s*none\s*;/);
}
function findTerminalSymbolsFontFaceRule(cssSource = css): string {
@@ -120,12 +120,12 @@ describe("terminal helper textarea CSS contract", () => {
expect(ruleBody).toMatch(/opacity:\s*0\.01\b/);
});
it("pins iOS text-size adjustment across the xterm measurement subtree", () => {
expectTextSizeAdjustPinned(findTerminalTextSizingRule());
it("disables iOS text-size adjustment across the xterm measurement subtree", () => {
expectTextSizeAdjustDisabledForExactXtermMetrics(findTerminalTextSizingRule());
});
it("pins iOS text-size adjustment on the SessionTerminal xterm viewport", () => {
expectTextSizeAdjustPinned(findSessionTerminalTextSizingRule());
it("disables iOS text-size adjustment on the SessionTerminal xterm viewport", () => {
expectTextSizeAdjustDisabledForExactXtermMetrics(findSessionTerminalTextSizingRule());
});
it("keeps a DOM glyph fallback mechanism outside xterm measurement options", () => {

View File

@@ -136,11 +136,14 @@ FN-6811 recurrence #6 found the attach terminal imported its own CSS chunk but d
/*
FNXC:Terminal 2026-06-18-07:34:
SessionTerminal hosts the same xterm DOM/canvas measurement subtree as TerminalModal under a different wrapper. Apply the FN-6638 real-iOS text-size-adjust invariant here too so CLI-agent attach terminals do not inherit Safari-inflated ASCII cell metrics while still using the shared font-load remeasure path for every preset and desktop WebGL.
FNXC:Terminal 2026-07-03-00:14:
Mirror TerminalModal's post-FN-7456 iPhone fix on the embedded attach surface: iOS keyboard-open 10px/12px terminal cells must ignore browser text-size inflation while keeping the user's exact terminal font-size preference and DOM-only prompt glyph fallback.
*/
.cli-session-terminal__viewport,
.cli-session-terminal__viewport * {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
-webkit-text-size-adjust: none;
text-size-adjust: none;
}
/*

View File

@@ -953,12 +953,15 @@ The terminal header pop-out/dock affordance is an icon-only utility control. It
/*
FNXC:Terminal 2026-06-18-07:04:
Real iOS Safari recurrence #4 kept wide ASCII cells even after unicode-range scoping and symbols-last ordering; the supplied harness measured AGENTS.md at the same 66.76px for every stack, so ordering is ruled out. Pin text-size adjustment across xterm's measurement subtree so mobile WebKit cannot inflate DOM/canvas cell metrics, while WebGL/desktop keep the same 100% text scale and every terminal preset still resolves through terminalPreferences.
Real iOS Safari recurrence #4 kept wide ASCII cells even after unicode-range scoping and symbols-last ordering; the supplied harness measured AGENTS.md at the same 66.76px for every stack, so ordering is ruled out. Pin text-size adjustment across xterm's measurement subtree so mobile WebKit cannot inflate DOM/canvas cell metrics, while WebGL/desktop keep the same exact user-selected terminal font size and every terminal preset still resolves through terminalPreferences.
FNXC:Terminal 2026-07-03-00:10:
Post-FN-7456 real iPhone Safari can still render initial keyboard-open 12px terminal text with excessive inter-character spacing when browser text-size adjustment inflates the xterm measurement subtree. Disable text-size adjustment, not the terminal font-size preference, so WebKit measures the same 10px/12px ASCII cells that xterm renders before any close/open, orientation, reconnect, or font reset can repair spacing.
*/
.terminal-xterm,
.terminal-xterm * {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
-webkit-text-size-adjust: none;
text-size-adjust: none;
}
/*

View File

@@ -34,6 +34,12 @@ function expectMeasurementSafeFontStack(stack: string): void {
expect(families).not.toContain(TERMINAL_SYMBOLS_FONT_FAMILY);
}
function expectTextSizeAdjustmentDisabledForExactXtermMetrics(cssSource: string): void {
const match = cssSource.match(/\.terminal-xterm\s*,\s*\.terminal-xterm \*\s*\{([^}]*)\}/);
expect(match?.[1] ?? "").toMatch(/-webkit-text-size-adjust\s*:\s*none\s*;/);
expect(match?.[1] ?? "").toMatch(/text-size-adjust\s*:\s*none\s*;/);
}
// Mock hooks and API
vi.mock("../../hooks/useTerminal", () => ({
useTerminal: vi.fn(),
@@ -5095,6 +5101,7 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", ()
await waitFor(() => expect(resizeForInitialIOSKeyboard).toHaveBeenCalledWith(80, 24));
expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string);
expect(mockTerminalInstance.options.fontSize).toBe(12);
expectTextSizeAdjustmentDisabledForExactXtermMetrics(terminalModalCss);
} finally {
helperTextarea.remove();
Object.defineProperty(window, "screen", { configurable: true, value: originalScreen });