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) <noreply@runfusion.ai>
This commit is contained in:
@@ -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.
|
||||
|
||||
<!-- FNXC:Terminal 2026-07-11-18:20: FN-7824 first-launch terminal sockets auto-retry with capped backoff until the first successful open, so the manual Reconnect affordance is reserved for terminal sessions that already connected and then exhaust their mid-session reconnect budget. -->
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<TerminalFloatSize>(() => readTerminalFloatSize(projectId));
|
||||
const [floatingPosition, setFloatingPosition] = useState<TerminalFloatPosition>(() => 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.
|
||||
|
||||
@@ -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(<TerminalModal isOpen={true} onClose={mockOnClose} projectId={projectId} />);
|
||||
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(<TerminalModal isOpen={true} onClose={mockOnClose} projectId={projectId} />);
|
||||
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] ?? "";
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user