From 973c978f910e9f01c8d67b9d7bf3ee1342abffc2 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 24 Jul 2026 00:59:42 -0700 Subject: [PATCH] FN-8557: preserve terminal geometry on tablets Keep floating terminal controls available on touch tablets across responsive and keyboard viewport changes. - Classify known tablet screens separately from phone fallbacks. - Restore tablet floating and docked geometry at the 768px breakpoint with larger touch resize targets. - Cover keyboard-shrunken and breakpoint tablet interactions, and document the responsive behavior. Files changed: docs/dashboard-guide.md | 2 +- .../dashboard/app/components/TerminalModal.css | 76 +++++++++-- .../dashboard/app/components/TerminalModal.tsx | 33 ++--- .../components/__tests__/TerminalModal.test.tsx | 150 +++++++++++++++++++++ .../app/hooks/__tests__/useViewportMode.test.ts | 14 ++ packages/dashboard/app/hooks/useViewportMode.ts | 33 ++++- 6 files changed, 277 insertions(+), 31 deletions(-) Fusion-Task-Id: FN-8557 Fusion-Task-Lineage: cd334682-e70a-4531-ab53-c5c0898f0bf1 Co-authored-by: Fusion (runfusion.ai) --- docs/dashboard-guide.md | 2 +- .../app/components/TerminalModal.css | 76 ++++++++- .../app/components/TerminalModal.tsx | 33 ++-- .../__tests__/TerminalModal.test.tsx | 150 ++++++++++++++++++ .../hooks/__tests__/useViewportMode.test.ts | 14 ++ .../dashboard/app/hooks/useViewportMode.ts | 33 +++- 6 files changed, 277 insertions(+), 31 deletions(-) diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index a69ee073a9..30a83dd9c7 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -764,7 +764,7 @@ Mailbox view shows inbox/outbox communication threads and unread state. When an ## Interactive Terminal -Fusion embeds a terminal using xterm.js. Desktop and tablet use the footer status bar as the terminal launcher; mobile keeps the full-screen terminal path. +Fusion embeds a terminal using xterm.js. Desktop and tablet use the footer status bar as the terminal launcher; mobile keeps the full-screen terminal path. Known touch tablets, including at the 768px responsive boundary, retain docked/floating presentation rather than falling back to the phone sheet. Their saved floating size and position and touch drag/edge-or-corner resize controls stay available when a software keyboard shortens the visual viewport. True narrow phones, including folded panes and short phone landscapes, intentionally remain full-screen. On first launch or first open, the terminal keeps reconnecting automatically until its initial WebSocket opens; it should show **Reconnecting...** during that cold-start recovery rather than requiring a manual **Reconnect** click. If an already-connected terminal drops and exhausts its bounded reconnect budget, Fusion then parks it as **Disconnected** and surfaces the manual **Reconnect** control. diff --git a/packages/dashboard/app/components/TerminalModal.css b/packages/dashboard/app/components/TerminalModal.css index 29a3619c62..a9f2a3362a 100644 --- a/packages/dashboard/app/components/TerminalModal.css +++ b/packages/dashboard/app/components/TerminalModal.css @@ -241,11 +241,17 @@ The floating-mode header is the move grip. `touch-action: none` is required so a touch-action: none; } +/* +FNXC:TerminalModalControls 2026-07-24-12:35: +Tablet operators resize the global floating terminal with the same edge and corner targets as other +floating windows. Use tokenized, larger hit regions so touch gestures are discoverable without +adding a second header or exposing controls in the fixed phone and embedded presentations. +*/ .terminal-floating-resize-handle--n, .terminal-floating-resize-handle--s { - left: var(--space-sm); - right: var(--space-sm); - height: var(--space-sm); + left: var(--space-md); + right: var(--space-md); + height: var(--space-md); cursor: ns-resize; } @@ -254,9 +260,9 @@ The floating-mode header is the move grip. `touch-action: none` is required so a .terminal-floating-resize-handle--e, .terminal-floating-resize-handle--w { - top: var(--space-sm); - bottom: var(--space-sm); - width: var(--space-sm); + top: var(--space-md); + bottom: var(--space-md); + width: var(--space-md); cursor: ew-resize; } @@ -267,8 +273,8 @@ The floating-mode header is the move grip. `touch-action: none` is required so a .terminal-floating-resize-handle--nw, .terminal-floating-resize-handle--se, .terminal-floating-resize-handle--sw { - width: var(--space-lg); - height: var(--space-lg); + width: var(--space-xl); + height: var(--space-xl); } .terminal-floating-resize-handle--ne { top: 0; right: 0; cursor: nesw-resize; } @@ -1885,8 +1891,62 @@ The Android keyboard-open recurrence can start with a touch-primary visualViewpo min-width: 36px; min-height: 36px; } + + /* + FNXC:TerminalModalControls 2026-07-24-01:10: + A known touch tablet can share this CSS-width breakpoint with a phone. The React + classifier marks it explicitly, so restore the stored floating/docked geometry + after the phone fallback rules (which use !important to defeat stale phone sizes). + This keeps the shared header move grip and all eight resize targets reachable at + exactly 768px without exposing any geometry controls on true-phone sheets. + */ + .modal.terminal-modal.terminal-modal--tablet.terminal-modal--floating { + min-width: calc(var(--space-xl) * 20) !important; + min-height: calc(var(--space-xl) * 13.333) !important; + width: var(--terminal-float-width) !important; + height: var(--terminal-float-height) !important; + max-width: calc(100vw - (var(--space-lg) * 2)) !important; + max-height: calc(100dvh - (var(--space-lg) * 2)) !important; + border-radius: var(--radius-lg); + border: 1px solid var(--border); + } + + .modal.terminal-modal.terminal-modal--tablet.terminal-modal--docked { + min-width: 0 !important; + min-height: calc(var(--space-xl) * 10) !important; + width: 100vw !important; + height: var(--terminal-docked-height) !important; + max-width: none !important; + max-height: calc(100dvh - (var(--space-xl) * 4)) !important; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + border: 1px solid var(--border); + } + + .modal.terminal-modal.terminal-modal--tablet .terminal-header { + flex-wrap: nowrap; + row-gap: 0; + padding-top: 0; + overflow: visible; + } + + .modal.terminal-modal.terminal-modal--tablet .terminal-tabs { + display: flex; + } + + .modal.terminal-modal.terminal-modal--tablet .terminal-mobile-tabs { + display: none; + } + + .modal.terminal-modal.terminal-modal--tablet .terminal-title { + display: flex; + } + + .terminal-below-host:has(.terminal-modal.terminal-modal--tablet) { + display: flex; + } } + @media (max-width: 768px) { .terminal-below-host { display: none; diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index c1b6f38a3d..aab47a353b 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -34,6 +34,7 @@ import { import { useTerminal } from "../hooks/useTerminal"; import { useTerminalSessions } from "../hooks/useTerminalSessions"; import { useWorkspaces } from "../hooks/useWorkspaces"; +import { getViewportMode, isMobileViewport } from "../hooks/useViewportMode"; import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack"; import { getPathBasename } from "../utils/pathDisplay"; import { @@ -336,16 +337,6 @@ function getTerminalViewportWidth(hasTouchScreen = false): number { return layoutWidth; } -function getTerminalViewportHeight(hasTouchScreen = false): number { - if (typeof window === "undefined") return Number.POSITIVE_INFINITY; - const layoutHeight = window.innerHeight; - const visualHeight = window.visualViewport?.height; - if (hasTouchScreen && typeof visualHeight === "number" && visualHeight > 0) { - return Math.min(layoutHeight, visualHeight); - } - return layoutHeight; -} - /** Whether the current device is likely mobile (touch-primary, small viewport). */ function isMobileDevice(): boolean { if (typeof window === "undefined") return false; @@ -356,13 +347,15 @@ function isMobileDevice(): boolean { } function isTerminalMobileViewport(): boolean { - if (typeof window === "undefined") return false; - const hasTouchScreen = "ontouchstart" in window || navigator.maxTouchPoints > 0; /* - FNXC:Terminal 2026-07-01-11:46: - Android foldables can expose a tablet-sized layout viewport while the current visualViewport is the folded phone pane. Treat touch-primary visualViewport width as the terminal mobile breakpoint so the first xterm fit uses the fullscreen/mobile shell and keyboard vars before any unfold/orientation event can repair stale desktop geometry. + FNXC:TerminalModalControls 2026-07-24-12:30: + The global terminal must use the canonical viewport contract rather than a terminal-local + visual-height shortcut. A software keyboard can shrink a tablet below the phone landscape + height without changing its physical screen, so it must retain docked/floating move and resize + controls. Canonical detection still makes true narrow phones, short phone landscapes, and folded + touch panes full-screen while preserving stored tablet/desktop geometry through transitions. */ - return window.innerWidth <= 768 || (hasTouchScreen && (getTerminalViewportWidth(true) <= 768 || getTerminalViewportHeight(true) <= 480)); + return isMobileViewport(); } interface TabsOverflowMeasurement { @@ -586,6 +579,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG const [floatingSize, setFloatingSize] = useState(() => readTerminalFloatSize(projectId)); const [floatingPosition, setFloatingPosition] = useState(() => readTerminalFloatPosition(readTerminalFloatSize(projectId), projectId)); const [isMobileTerminal, setIsMobileTerminal] = useState(() => isTerminalMobileViewport()); + const [isTabletTerminal, setIsTabletTerminal] = useState(() => getViewportMode() === "tablet"); const [tabsOverflow, setTabsOverflow] = useState(false); /* FNXC:Terminal 2026-07-10-00:00: @@ -667,6 +661,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG */ const updateViewportMode = () => { setIsMobileTerminal(isTerminalMobileViewport()); + setIsTabletTerminal(getViewportMode() === "tablet"); }; updateViewportMode(); window.addEventListener("resize", updateViewportMode); @@ -2484,7 +2479,13 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG const showManualStart = isReady && autoCreateDisabled && !activeTab && !bootstrapError; // FNXC:Terminal 2026-06-23-04:30: Always carry the base `terminal-modal-overlay` class so the no-dim/no-blur rule applies in EVERY mode (docked, floating, AND the mobile/default sheet that is neither) — the terminal must never dim the page behind it. const overlayClassName = `modal-overlay open terminal-modal-overlay${isDockedMode ? " terminal-modal-overlay--docked" : ""}${isFloatingMode ? " terminal-modal-overlay--floating" : ""}`; - const modalClassName = `modal terminal-modal${isMobileTerminal && !embedded ? " terminal-modal--mobile" : ""}${isDockedMode ? " terminal-modal--docked" : ""}${isFloatingMode ? " terminal-modal--floating" : ""}${isBelowMode ? " terminal-modal--below" : ""}${embedded ? " terminal-modal--embedded" : ""}`; + /* + FNXC:TerminalModalControls 2026-07-24-01:10: + CSS still has a width-based phone media query for true-phone fallback. Mark a known tablet + explicitly so its floating/docked geometry wins at the 768px boundary rather than inheriting + the phone full-screen shell. Embedded terminals remain parent-owned and never receive this chrome. + */ + const modalClassName = `modal terminal-modal${isMobileTerminal && !embedded ? " terminal-modal--mobile" : ""}${isTabletTerminal && !isMobileTerminal && !embedded ? " terminal-modal--tablet" : ""}${isDockedMode ? " terminal-modal--docked" : ""}${isFloatingMode ? " terminal-modal--floating" : ""}${isBelowMode ? " terminal-modal--below" : ""}${embedded ? " terminal-modal--embedded" : ""}`; /* FNXC:TerminalWorkspaces 2026-07-13-00:00: The workspace picker menu is portaled to `document.body`, so floating terminal mode must compare it in the same root stacking context as the panel. Keep the menu one layer above the panel's shared `floatingZ`; otherwise the fixed CSS fallback band sits below the 10100+ floating stack and the menu appears invisible behind the modal. diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx index e1ecd5bcf9..5b9b988ba3 100644 --- a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx @@ -1219,6 +1219,156 @@ describe("TerminalModal", () => { }); }); + it("keeps a keyboard-shrunken touch tablet floating, movable, and resizable", async () => { + const projectId = "touch-tablet-terminal-geometry"; + const previousInnerWidth = window.innerWidth; + const previousInnerHeight = window.innerHeight; + const previousScreen = Object.getOwnPropertyDescriptor(window, "screen"); + const previousMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, "maxTouchPoints"); + const previousVisualViewport = window.visualViewport; + Object.defineProperty(window, "innerWidth", { configurable: true, value: 1200 }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 900 }); + Object.defineProperty(window, "screen", { configurable: true, value: { width: 1024, height: 768 } }); + Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 1 }); + Object.defineProperty(window, "visualViewport", { + configurable: true, + value: { width: 900, height: 400, addEventListener: vi.fn(), removeEventListener: vi.fn() }, + }); + vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ + matches: query === "(max-height: 480px)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + window.localStorage.setItem(`fusion:terminal-display-mode-${projectId}`, "floating"); + + try { + render(); + const modal = await screen.findByTestId("terminal-modal"); + await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled()); + expect(modal).toHaveClass("terminal-modal--floating"); + expect(screen.getByTestId("terminal-floating-resize-se")).toHaveAttribute("aria-label", "Resize terminal window"); + + const fitCallBaseline = mockFitAddonFit.mock.calls.length; + const resizeHandle = screen.getByTestId("terminal-floating-resize-se") as HTMLElement & { + setPointerCapture: (pointerId: number) => void; + releasePointerCapture: (pointerId: number) => void; + }; + resizeHandle.setPointerCapture = vi.fn(); + resizeHandle.releasePointerCapture = vi.fn(); + fireEvent.pointerDown(resizeHandle, { pointerId: 41, pointerType: "touch", clientX: 100, clientY: 100 }); + fireEvent.pointerMove(resizeHandle, { pointerId: 99, pointerType: "touch", clientX: 300, clientY: 300 }); + fireEvent.pointerMove(resizeHandle, { pointerId: 41, pointerType: "touch", clientX: 180, clientY: 170 }); + fireEvent.pointerUp(resizeHandle, { pointerId: 41, pointerType: "touch" }); + + await waitFor(() => { + expect(window.localStorage.getItem(`fusion:terminal-modal-size-${projectId}`)).toBe(JSON.stringify({ width: 1040, height: 630 })); + expect(mockFitAddonFit.mock.calls.length).toBeGreaterThan(fitCallBaseline); + }); + + const header = modal.querySelector(".terminal-header") as HTMLElement & { + setPointerCapture: (pointerId: number) => void; + releasePointerCapture: (pointerId: number) => void; + }; + header.setPointerCapture = vi.fn(); + header.releasePointerCapture = vi.fn(); + fireEvent.pointerDown(header, { pointerId: 42, pointerType: "touch", clientX: 300, clientY: 100 }); + fireEvent.pointerMove(header, { pointerId: 42, pointerType: "touch", clientX: 200, clientY: 140 }); + fireEvent.pointerUp(header, { pointerId: 42, pointerType: "touch" }); + + await waitFor(() => { + expect(JSON.parse(window.localStorage.getItem(`fusion:terminal-float-pos-${projectId}`) ?? "{}")).toEqual({ x: 44, y: 56 }); + }); + + fireEvent.pointerDown(header, { pointerId: 43, pointerType: "touch", clientX: 200, clientY: 140 }); + fireEvent.pointerMove(header, { pointerId: 43, pointerType: "touch", clientX: 100, clientY: 140 }); + fireEvent.pointerCancel(header, { pointerId: 43, pointerType: "touch" }); + expect(JSON.parse(window.localStorage.getItem(`fusion:terminal-float-pos-${projectId}`) ?? "{}")).toEqual({ x: 16, y: 56 }); + expect(header.releasePointerCapture).toHaveBeenCalledWith(43); + } finally { + Object.defineProperty(window, "innerWidth", { configurable: true, value: previousInnerWidth }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: previousInnerHeight }); + if (previousScreen) Object.defineProperty(window, "screen", previousScreen); + if (previousMaxTouchPoints) Object.defineProperty(navigator, "maxTouchPoints", previousMaxTouchPoints); + Object.defineProperty(window, "visualViewport", { configurable: true, value: previousVisualViewport }); + } + }); + + it("keeps a touch tablet at the 768px boundary floating, movable, and resizable", async () => { + const projectId = "tablet-boundary-terminal-geometry"; + const previousInnerWidth = window.innerWidth; + const previousInnerHeight = window.innerHeight; + const previousScreen = Object.getOwnPropertyDescriptor(window, "screen"); + const previousMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, "maxTouchPoints"); + Object.defineProperty(window, "innerWidth", { configurable: true, value: 768 }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 1024 }); + Object.defineProperty(window, "screen", { configurable: true, value: { width: 768, height: 1024 } }); + Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 1 }); + vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ + matches: query === "(max-width: 768px)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + window.localStorage.setItem(`fusion:terminal-display-mode-${projectId}`, "floating"); + const styleEl = document.createElement("style"); + styleEl.textContent = loadAllAppCss(); + document.head.appendChild(styleEl); + + try { + render(); + const modal = await screen.findByTestId("terminal-modal"); + await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled()); + expect(modal).toHaveClass("terminal-modal--tablet", "terminal-modal--floating"); + // The 768px CSS fallback is full-screen only for true phones. A known + // tablet must win that cascade with its stored floating geometry. + const modalStyle = getComputedStyle(modal); + expect(modalStyle.width).toBe("var(--terminal-float-width)"); + expect(modalStyle.height).toBe("var(--terminal-float-height)"); + expect(modalStyle.maxWidth).toBe("calc(100vw - (var(--space-lg) * 2))"); + + const resizeHandle = screen.getByTestId("terminal-floating-resize-se") as HTMLElement & { + setPointerCapture: (pointerId: number) => void; + releasePointerCapture: (pointerId: number) => void; + }; + resizeHandle.setPointerCapture = vi.fn(); + resizeHandle.releasePointerCapture = vi.fn(); + fireEvent.pointerDown(resizeHandle, { pointerId: 51, pointerType: "touch", clientX: 200, clientY: 200 }); + fireEvent.pointerMove(resizeHandle, { pointerId: 51, pointerType: "touch", clientX: 120, clientY: 180 }); + fireEvent.pointerUp(resizeHandle, { pointerId: 51, pointerType: "touch" }); + await waitFor(() => { + expect(window.localStorage.getItem(`fusion:terminal-modal-size-${projectId}`)).toBe(JSON.stringify({ width: 656, height: 540 })); + }); + + const header = modal.querySelector(".terminal-header") as HTMLElement & { + setPointerCapture: (pointerId: number) => void; + releasePointerCapture: (pointerId: number) => void; + }; + header.setPointerCapture = vi.fn(); + header.releasePointerCapture = vi.fn(); + fireEvent.pointerDown(header, { pointerId: 52, pointerType: "touch", clientX: 100, clientY: 100 }); + fireEvent.pointerMove(header, { pointerId: 52, pointerType: "touch", clientX: 180, clientY: 140 }); + fireEvent.pointerUp(header, { pointerId: 52, pointerType: "touch" }); + await waitFor(() => { + expect(window.localStorage.getItem(`fusion:terminal-float-pos-${projectId}`)).toBe(JSON.stringify({ x: 96, y: 56 })); + }); + } finally { + styleEl.remove(); + Object.defineProperty(window, "innerWidth", { configurable: true, value: previousInnerWidth }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: previousInnerHeight }); + if (previousScreen) Object.defineProperty(window, "screen", previousScreen); + if (previousMaxTouchPoints) Object.defineProperty(navigator, "maxTouchPoints", previousMaxTouchPoints); + } + }); + it("keeps the floating terminal touch-draggable with theme-controlled shadow", () => { const panelRule = terminalModalCss.match(/\.modal\.terminal-modal\.terminal-modal--floating\s*\{([^}]*)\}/)?.[1] ?? ""; const headerRule = terminalModalCss.match(/\.terminal-header--draggable\s*\{([^}]*)\}/)?.[1] ?? ""; diff --git a/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts b/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts index b95778d8d4..29b4445e66 100644 --- a/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts @@ -150,6 +150,20 @@ describe("useViewportMode", () => { expect(renderHook(() => useViewportMode()).result.current).toBe("tablet"); }); + it("keeps a touch tablet at the 768px boundary out of the phone presentation", () => { + const originalMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, "maxTouchPoints"); + stubScreen(768, 1024); + Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 1 }); + installViewportMedia({ width: true, height: false, tablet: false }); + + try { + expect(isMobileViewport()).toBe(false); + expect(getViewportMode()).toBe("tablet"); + } finally { + if (originalMaxTouchPoints) Object.defineProperty(navigator, "maxTouchPoints", originalMaxTouchPoints); + } + }); + it("keeps desktop mode when only the short-height clause matches on a desktop-class screen", () => { stubScreen(1920, 1080); installViewportMedia({ width: false, height: true, tablet: false }); diff --git a/packages/dashboard/app/hooks/useViewportMode.ts b/packages/dashboard/app/hooks/useViewportMode.ts index 219f0b4418..92c9da172b 100644 --- a/packages/dashboard/app/hooks/useViewportMode.ts +++ b/packages/dashboard/app/hooks/useViewportMode.ts @@ -49,11 +49,19 @@ function getTouchVisualViewportHeight(): number | null { // `(max-height: 480px)`) but never the device's physical screen. Only treat a // short viewport as a landscape phone when the smaller physical screen edge is // phone-class. Fail safe (return false) when screen data is unavailable. -function isPhoneClassScreen(): boolean { +function hasKnownPhysicalScreenSize(): boolean { if (typeof window === "undefined" || !window.screen) return false; const { width, height } = window.screen; - if (!width || !height) return false; - return Math.min(width, height) <= 480; + return Boolean(width && height); +} + +function isPhoneClassScreen(): boolean { + if (!hasKnownPhysicalScreenSize()) return false; + return Math.min(window.screen.width, window.screen.height) <= 480; +} + +function isTabletClassTouchScreen(): boolean { + return hasTouchScreen() && hasKnownPhysicalScreenSize() && !isPhoneClassScreen(); } export function isMobileViewport(): boolean { @@ -64,15 +72,28 @@ export function isMobileViewport(): boolean { FNXC:ViewportMode 2026-07-01-11:56: Android foldables can expose a wide layout viewport while visualViewport is the folded phone pane. Treat touch-primary visualViewport width as the mobile breakpoint so terminal surfaces render mobile controls and fit xterm from the initial folded geometry, not a stale desktop/tablet shell. */ - return window.matchMedia(MOBILE_WIDTH_MEDIA_QUERY).matches || - (visualWidth !== null && visualWidth <= 768) || + /* + FNXC:TerminalModalControls 2026-07-24-14:15: + A touch viewport exactly at the 768px CSS boundary can be a portrait tablet, not a phone. + Keep the physical layout/visual-width fallback for delayed media-query and orientation updates, + but route a known tablet-class physical screen through tablet geometry controls. Unknown screen + dimensions retain the conservative CSS-only phone path, while narrow foldable panes remain phones. + */ + const hasNarrowWidth = window.innerWidth <= 768 || + window.matchMedia(MOBILE_WIDTH_MEDIA_QUERY).matches || + (visualWidth !== null && visualWidth <= 768); + return (hasNarrowWidth && !isTabletClassTouchScreen()) || ((window.matchMedia(MOBILE_HEIGHT_MEDIA_QUERY).matches || (visualHeight !== null && visualHeight <= 480)) && isPhoneClassScreen()); } export function getViewportMode(): ViewportMode { if (typeof window === "undefined") return "desktop"; if (isMobileViewport()) return "mobile"; - if (window.matchMedia("(min-width: 769px) and (max-width: 1024px)").matches) return "tablet"; + const isTabletBoundary = window.innerWidth <= 768 || + window.matchMedia(MOBILE_WIDTH_MEDIA_QUERY).matches || + (getTouchVisualViewportWidth() ?? Number.POSITIVE_INFINITY) <= 768; + if (window.matchMedia("(min-width: 769px) and (max-width: 1024px)").matches || + (isTabletBoundary && isTabletClassTouchScreen())) return "tablet"; return "desktop"; }