FN-7388: Fix folded Android terminal mobile sizing
Fix Android foldable terminal surfaces so initial folded panes use mobile viewport geometry.\n\n- Use touch visualViewport dimensions when classifying terminal mobile mode.\n- Apply mobile TerminalModal shell styles outside media queries for folded Android panes.\n- Cover folded Android viewport behavior in terminal and viewport-mode tests.\n- Document the folded viewport baseline invariant and add a patch changeset.\n\nFiles changed:\n .changeset/fn-7388-android-folded-terminal-spacing.md | 7 +++\n docs/solutions/ui-bugs/mobile-terminal-folded-viewport-baseline.md | 8 ++-\n packages/dashboard/app/components/SessionTerminal.tsx | 18 +++++-\n packages/dashboard/app/components/TerminalModal.css | 37 +++++++++++\n packages/dashboard/app/components/TerminalModal.tsx | 31 ++++++++-\n packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx | 31 +++++++++\n packages/dashboard/app/components/__tests__/TerminalModal.test.tsx | 73 ++++++++++++++++++++++\n packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts | 29 ++++++++-\n packages/dashboard/app/hooks/useViewportMode.ts | 26 +++++++-\n 9 files changed, 251 insertions(+), 9 deletions(-) Fusion-Task-Id: FN-7388 Fusion-Task-Lineage: cdf2ef0d-be1f-4414-9113-17ffebdecde5 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7388-android-folded-terminal-spacing.md
Normal file
7
.changeset/fn-7388-android-folded-terminal-spacing.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Fix folded Android mobile terminal spacing on initial open.
|
||||||
|
category: fix
|
||||||
|
dev: Terminal mobile detection now honors touch visualViewport width for TerminalModal and SessionTerminal.
|
||||||
@@ -18,8 +18,10 @@ related_components:
|
|||||||
- packages/dashboard/app/components/TerminalModal.tsx
|
- packages/dashboard/app/components/TerminalModal.tsx
|
||||||
- packages/dashboard/app/hooks/useMobileKeyboard.ts
|
- packages/dashboard/app/hooks/useMobileKeyboard.ts
|
||||||
- packages/dashboard/app/components/SessionTerminal.tsx
|
- packages/dashboard/app/components/SessionTerminal.tsx
|
||||||
|
- packages/dashboard/app/hooks/useViewportMode.ts
|
||||||
- FN-7281
|
- FN-7281
|
||||||
- FN-7289
|
- FN-7289
|
||||||
|
- FN-7388
|
||||||
tags:
|
tags:
|
||||||
- terminal
|
- terminal
|
||||||
- xterm
|
- xterm
|
||||||
@@ -35,7 +37,7 @@ tags:
|
|||||||
|
|
||||||
The terminal has two mobile xterm surfaces: the PTY `TerminalModal` and the embedded `SessionTerminal`. Both depend on visualViewport-derived keyboard metrics before fitting xterm rows/cols. On iOS-style browsers, `innerHeight` can shrink with the keyboard, so the code keeps a baseline viewport height captured while the keyboard is closed.
|
The terminal has two mobile xterm surfaces: the PTY `TerminalModal` and the embedded `SessionTerminal`. Both depend on visualViewport-derived keyboard metrics before fitting xterm rows/cols. On iOS-style browsers, `innerHeight` can shrink with the keyboard, so the code keeps a baseline viewport height captured while the keyboard is closed.
|
||||||
|
|
||||||
A foldable device can first expose an unfolded/wide closed baseline, then settle to a narrower folded baseline before the keyboard opens. If the folded closed sample is shorter than the previous baseline and the baseline only ever grows, the later keyboard-open sample overestimates the overlap. Conversely, a fold/orientation width sample can arrive after xterm's helper textarea is focused and the soft keyboard is already open; if that focused sample replaces the baseline, the keyboard-open height looks closed and clears the terminal CSS variables. A recurrence also appears when the terminal first renders after the helper/input is already focused and the soft keyboard is open: there is no prior closed visualViewport sample, so using the shrunken visualViewport as the baseline clears the overlap until a later unfold/orientation event supplies a usable layout. These stale or missing geometries make the terminal fit against the wrong box and can surface as premature wrapping or spaced ASCII such as `p n p m b u i l d`.
|
A foldable device can first expose an unfolded/wide closed baseline, then settle to a narrower folded baseline before the keyboard opens. If the folded closed sample is shorter than the previous baseline and the baseline only ever grows, the later keyboard-open sample overestimates the overlap. Conversely, a fold/orientation width sample can arrive after xterm's helper textarea is focused and the soft keyboard is already open; if that focused sample replaces the baseline, the keyboard-open height looks closed and clears the terminal CSS variables. A recurrence also appears when the terminal first renders after the helper/input is already focused and the soft keyboard is open: there is no prior closed visualViewport sample, so using the shrunken visualViewport as the baseline clears the overlap until a later unfold/orientation event supplies a usable layout. Android Chrome can also keep a tablet-sized layout viewport while `visualViewport.width` is the actual folded phone pane; if mobile detection only reads `innerWidth`/media queries, the terminal opens in desktop docked/floating geometry and xterm fits before the mobile shell is applied. These stale or missing geometries make the terminal fit against the wrong box and can surface as premature wrapping or spaced ASCII such as `p n p m b u i l d`.
|
||||||
|
|
||||||
## Solution
|
## Solution
|
||||||
|
|
||||||
@@ -46,6 +48,7 @@ Treat a keyboard-closed width/posture change as a new baseline, not as keyboard
|
|||||||
- When width changes and the viewport height is a settled folded value, replace the baseline before computing iOS fallback overlap.
|
- When width changes and the viewport height is a settled folded value, replace the baseline before computing iOS fallback overlap.
|
||||||
- Gate that replacement to keyboard-closed samples; if a keyboard-focusable element is active, keep the previous baseline so a focused keyboard-open folded sample cannot zero out the overlap.
|
- Gate that replacement to keyboard-closed samples; if a keyboard-focusable element is active, keep the previous baseline so a focused keyboard-open folded sample cannot zero out the overlap.
|
||||||
- When the first sample is already focused and keyboard-open, prefer the layout viewport height (`documentElement.clientHeight` when available) over the shrunken visualViewport height so overlap and `--vv-height` are meaningful before any unfold repair.
|
- When the first sample is already focused and keyboard-open, prefer the layout viewport height (`documentElement.clientHeight` when available) over the shrunken visualViewport height so overlap and `--vv-height` are meaningful before any unfold repair.
|
||||||
|
- Treat touch-primary `visualViewport.width` as a mobile breakpoint input for both TerminalModal and shared viewport-mode consumers so folded Android panes render mobile controls and fullscreen sizing even when the layout viewport remains wide.
|
||||||
- Keep xterm's measured font family symbols-free; the fix is viewport measurement, not a letter-spacing or cell-width workaround.
|
- Keep xterm's measured font family symbols-free; the fix is viewport measurement, not a letter-spacing or cell-width workaround.
|
||||||
|
|
||||||
## Regression coverage
|
## Regression coverage
|
||||||
@@ -53,7 +56,8 @@ Treat a keyboard-closed width/posture change as a new baseline, not as keyboard
|
|||||||
Guard the invariant at three seams:
|
Guard the invariant at three seams:
|
||||||
|
|
||||||
- `TerminalModal.test.tsx` simulates initial folded keyboard-open startup with ASCII (`pnpm build`) and prompt glyph output, duplicate visualViewport/orientation events, and asserts `--keyboard-overlap` / `--vv-height` plus xterm resize happen before any unfold. It also simulates unfolded closed → folded closed → folded keyboard-open and covers a focused folded keyboard-open sample so posture re-baselining cannot clear those CSS variables.
|
- `TerminalModal.test.tsx` simulates initial folded keyboard-open startup with ASCII (`pnpm build`) and prompt glyph output, duplicate visualViewport/orientation events, and asserts `--keyboard-overlap` / `--vv-height` plus xterm resize happen before any unfold. It also simulates unfolded closed → folded closed → folded keyboard-open and covers a focused folded keyboard-open sample so posture re-baselining cannot clear those CSS variables.
|
||||||
- `SessionTerminal.mobile.test.tsx` proves the embedded mobile input bar uses layout/folded metrics for both initial focused keyboard-open startup and folded-baseline replacement, while keeping the xterm measured font stack symbols-free.
|
- `SessionTerminal.mobile.test.tsx` proves the embedded mobile input bar uses layout/folded metrics for both initial focused keyboard-open startup and folded-baseline replacement, while keeping the xterm measured font stack symbols-free. It also covers the Android folded case where media queries are desktop/tablet but touch `visualViewport.width` is phone-sized.
|
||||||
- `useMobileKeyboard.test.ts` covers the shared hook so future consumers inherit the posture-aware baseline behavior.
|
- `useMobileKeyboard.test.ts` covers the shared hook so future consumers inherit the posture-aware baseline behavior.
|
||||||
|
- `useViewportMode.test.ts` covers touch visualViewport width as a mobile-mode input for foldables whose layout viewport remains wide.
|
||||||
|
|
||||||
Existing terminal tests continue to cover symbols-free xterm font stacks, glyph fallback for Nerd Font/powerline output, duplicate visualViewport resize coalescing, keyboard close clearing, undefined visualViewport, tab-switch scrollback replay, and desktop/tablet terminal modes.
|
Existing terminal tests continue to cover symbols-free xterm font stacks, glyph fallback for Nerd Font/powerline output, duplicate visualViewport resize coalescing, keyboard close clearing, undefined visualViewport, tab-switch scrollback replay, and desktop/tablet terminal modes.
|
||||||
|
|||||||
@@ -81,15 +81,29 @@ function useIsMobileViewport(): boolean {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const mql = window.matchMedia(MOBILE_MEDIA_QUERY);
|
const mql = window.matchMedia(MOBILE_MEDIA_QUERY);
|
||||||
|
const visualViewport = window.visualViewport;
|
||||||
const onChange = () => setIsMobile(isMobileViewport());
|
const onChange = () => setIsMobile(isMobileViewport());
|
||||||
onChange();
|
onChange();
|
||||||
|
if (typeof visualViewport?.addEventListener === "function") {
|
||||||
|
visualViewport.addEventListener("resize", onChange);
|
||||||
|
}
|
||||||
// Safari < 14 only has addListener/removeListener.
|
// Safari < 14 only has addListener/removeListener.
|
||||||
if (typeof mql.addEventListener === "function") {
|
if (typeof mql.addEventListener === "function") {
|
||||||
mql.addEventListener("change", onChange);
|
mql.addEventListener("change", onChange);
|
||||||
return () => mql.removeEventListener("change", onChange);
|
return () => {
|
||||||
|
mql.removeEventListener("change", onChange);
|
||||||
|
if (typeof visualViewport?.removeEventListener === "function") {
|
||||||
|
visualViewport.removeEventListener("resize", onChange);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
mql.addListener(onChange);
|
mql.addListener(onChange);
|
||||||
return () => mql.removeListener(onChange);
|
return () => {
|
||||||
|
mql.removeListener(onChange);
|
||||||
|
if (typeof visualViewport?.removeEventListener === "function") {
|
||||||
|
visualViewport.removeEventListener("resize", onChange);
|
||||||
|
}
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return isMobile;
|
return isMobile;
|
||||||
|
|||||||
@@ -1342,6 +1342,43 @@ Footer reads left-to-right: text-size control, then the relocated Clear/Shortcut
|
|||||||
border-color: var(--text-muted);
|
border-color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:Terminal 2026-07-01-11:49:
|
||||||
|
Android folded Chrome can keep a wide layout viewport while visualViewport is the narrow folded pane. The React mobile classifier adds terminal-modal--mobile from the visual viewport, so duplicate the critical fullscreen shell outside CSS media queries and let xterm measure the folded box on the initial fit.
|
||||||
|
*/
|
||||||
|
.modal.terminal-modal.terminal-modal--mobile {
|
||||||
|
min-width: 0 !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
width: 100vw !important;
|
||||||
|
max-width: 100vw !important;
|
||||||
|
min-height: 100dvh !important;
|
||||||
|
height: 100dvh !important;
|
||||||
|
max-height: 100dvh !important;
|
||||||
|
border-radius: 0;
|
||||||
|
border: none;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.terminal-modal.terminal-modal--mobile .terminal-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: var(--space-xs);
|
||||||
|
padding-top: env(safe-area-inset-top, 0);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.terminal-modal.terminal-modal--mobile .terminal-tabs,
|
||||||
|
.modal.terminal-modal.terminal-modal--mobile .terminal-title {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.terminal-modal.terminal-modal--mobile .terminal-mobile-tabs {
|
||||||
|
display: flex;
|
||||||
|
order: 1;
|
||||||
|
flex: 1 1 40%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* === Terminal Modal Mobile Responsive === */
|
/* === Terminal Modal Mobile Responsive === */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.modal.terminal-modal {
|
.modal.terminal-modal {
|
||||||
|
|||||||
@@ -292,19 +292,44 @@ export async function retryDynamicImport<T>(
|
|||||||
throw originalError ?? new Error("Dynamic import failed");
|
throw originalError ?? new Error("Dynamic import failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Effective viewport width for terminal mobile decisions. */
|
||||||
|
function getTerminalViewportWidth(hasTouchScreen = false): number {
|
||||||
|
if (typeof window === "undefined") return Number.POSITIVE_INFINITY;
|
||||||
|
const layoutWidth = window.innerWidth;
|
||||||
|
const visualWidth = window.visualViewport?.width;
|
||||||
|
if (hasTouchScreen && typeof visualWidth === "number" && visualWidth > 0) {
|
||||||
|
return Math.min(layoutWidth, visualWidth);
|
||||||
|
}
|
||||||
|
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). */
|
/** Whether the current device is likely mobile (touch-primary, small viewport). */
|
||||||
function isMobileDevice(): boolean {
|
function isMobileDevice(): boolean {
|
||||||
if (typeof window === "undefined") return false;
|
if (typeof window === "undefined") return false;
|
||||||
const hasTouchScreen =
|
const hasTouchScreen =
|
||||||
"ontouchstart" in window || navigator.maxTouchPoints > 0;
|
"ontouchstart" in window || navigator.maxTouchPoints > 0;
|
||||||
const isNarrow = window.innerWidth <= 768;
|
const isNarrow = getTerminalViewportWidth(hasTouchScreen) <= 768;
|
||||||
return hasTouchScreen && isNarrow;
|
return hasTouchScreen && isNarrow;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTerminalMobileViewport(): boolean {
|
function isTerminalMobileViewport(): boolean {
|
||||||
if (typeof window === "undefined") return false;
|
if (typeof window === "undefined") return false;
|
||||||
const hasTouchScreen = "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
const hasTouchScreen = "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
||||||
return window.innerWidth <= 768 || (hasTouchScreen && window.innerHeight <= 480);
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
return window.innerWidth <= 768 || (hasTouchScreen && (getTerminalViewportWidth(true) <= 768 || getTerminalViewportHeight(true) <= 480));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMacPlatform(): boolean {
|
function isMacPlatform(): boolean {
|
||||||
@@ -2051,7 +2076,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
|||||||
const isLoading = !isReady || (!activeTab && !bootstrapError);
|
const isLoading = !isReady || (!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.
|
// 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 overlayClassName = `modal-overlay open terminal-modal-overlay${isDockedMode ? " terminal-modal-overlay--docked" : ""}${isFloatingMode ? " terminal-modal-overlay--floating" : ""}`;
|
||||||
const modalClassName = `modal terminal-modal${isDockedMode ? " terminal-modal--docked" : ""}${isFloatingMode ? " terminal-modal--floating" : ""}`;
|
const modalClassName = `modal terminal-modal${isMobileTerminal ? " terminal-modal--mobile" : ""}${isDockedMode ? " terminal-modal--docked" : ""}${isFloatingMode ? " terminal-modal--floating" : ""}`;
|
||||||
const modalStyle = {
|
const modalStyle = {
|
||||||
...(keyboardOverlap > 0
|
...(keyboardOverlap > 0
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -180,6 +180,37 @@ describe("SessionTerminal (mobile)", () => {
|
|||||||
expect(screen.getByTestId("cli-terminal-mobile-input")).toBeTruthy();
|
expect(screen.getByTestId("cli-terminal-mobile-input")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses touch visualViewport width for Android folded initial mobile mode", async () => {
|
||||||
|
installMatchMedia({ width: false, height: false });
|
||||||
|
const originalVisualViewport = window.visualViewport;
|
||||||
|
const originalMaxTouchPoints = navigator.maxTouchPoints;
|
||||||
|
Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 1 });
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, writable: true, value: 900 });
|
||||||
|
Object.defineProperty(window, "innerHeight", { configurable: true, writable: true, value: 700 });
|
||||||
|
Object.defineProperty(window, "visualViewport", {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: {
|
||||||
|
width: 390,
|
||||||
|
height: 320,
|
||||||
|
offsetTop: 0,
|
||||||
|
offsetLeft: 0,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await renderMobile();
|
||||||
|
expect(screen.getByTestId("cli-terminal-mobile-bar")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cli-terminal-key-bar")).toBeTruthy();
|
||||||
|
expectMeasurementSafeFontStack(mockTerm.options.fontFamily as string);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(window, "visualViewport", { configurable: true, writable: true, value: originalVisualViewport });
|
||||||
|
Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: originalMaxTouchPoints });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("does not render the mobile bar off-mobile (desktop breakpoint)", async () => {
|
it("does not render the mobile bar off-mobile (desktop breakpoint)", async () => {
|
||||||
installMatchMedia(false);
|
installMatchMedia(false);
|
||||||
await renderMobile();
|
await renderMobile();
|
||||||
|
|||||||
@@ -4999,6 +4999,79 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", ()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats Android folded visualViewport width as mobile before initial terminal fit", async () => {
|
||||||
|
(window as any).ontouchstart = null;
|
||||||
|
window.localStorage.setItem(TERMINAL_FONT_SIZE_KEY, "10");
|
||||||
|
const listeners: Record<string, Array<() => void>> = { resize: [], scroll: [] };
|
||||||
|
const mockVV = {
|
||||||
|
width: 390,
|
||||||
|
height: 320,
|
||||||
|
offsetTop: 0,
|
||||||
|
offsetLeft: 0,
|
||||||
|
addEventListener: vi.fn((event: string, cb: () => void) => {
|
||||||
|
if (listeners[event]) listeners[event].push(cb);
|
||||||
|
}),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
};
|
||||||
|
Object.defineProperty(window, "visualViewport", {
|
||||||
|
value: mockVV,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(window, "innerWidth", {
|
||||||
|
value: 900,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(window, "innerHeight", {
|
||||||
|
value: 700,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(document.documentElement, "clientHeight", {
|
||||||
|
value: 700,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
const onDataListeners: Array<(data: string) => void> = [];
|
||||||
|
const resizeForFoldedAndroid = vi.fn();
|
||||||
|
mockUseTerminal.mockReturnValue(createMockTerminalState({
|
||||||
|
connectionStatus: "connected",
|
||||||
|
resize: resizeForFoldedAndroid,
|
||||||
|
onData: vi.fn((cb: (data: string) => void) => {
|
||||||
|
onDataListeners.push(cb);
|
||||||
|
return vi.fn();
|
||||||
|
}),
|
||||||
|
onScrollback: vi.fn((cb: (data: string) => void) => {
|
||||||
|
onDataListeners.push(cb);
|
||||||
|
return vi.fn();
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId("terminal-font-size-value")).toHaveTextContent("10px"));
|
||||||
|
await waitFor(() => {
|
||||||
|
const modal = screen.getByTestId("terminal-modal");
|
||||||
|
expect(modal).not.toHaveClass("terminal-modal--docked");
|
||||||
|
expect(modal).not.toHaveClass("terminal-modal--floating");
|
||||||
|
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("380px");
|
||||||
|
expect(modal.style.getPropertyValue("--vv-height")).toBe("320px");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(onDataListeners.length).toBeGreaterThan(0));
|
||||||
|
act(() => {
|
||||||
|
for (const cb of onDataListeners) {
|
||||||
|
cb("❯ pnpm build\r\n@fusion/dashboard build complete main\r\n");
|
||||||
|
}
|
||||||
|
for (const cb of listeners.resize) cb();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockTerminalInstance.write).toHaveBeenCalledWith(expect.stringContaining("pnpm build")));
|
||||||
|
await waitFor(() => expect(resizeForFoldedAndroid).toHaveBeenCalledWith(80, 24));
|
||||||
|
expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string);
|
||||||
|
expect(mockTerminalInstance.options.fontSize).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
it("detects keyboard on iOS Safari where innerHeight shrinks with visualViewport", async () => {
|
it("detects keyboard on iOS Safari where innerHeight shrinks with visualViewport", async () => {
|
||||||
// On iOS Safari, both window.innerHeight and visualViewport.height shrink.
|
// On iOS Safari, both window.innerHeight and visualViewport.height shrink.
|
||||||
// The primary formula (innerHeight - vv.offsetTop - vv.height) returns 0
|
// The primary formula (innerHeight - vv.offsetTop - vv.height) returns 0
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { act, renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { getViewportMode, MOBILE_MEDIA_QUERY, useViewportMode } from "../useViewportMode";
|
import { getViewportMode, isMobileViewport, MOBILE_MEDIA_QUERY, useViewportMode } from "../useViewportMode";
|
||||||
|
|
||||||
const TABLET_MEDIA_QUERY = "(min-width: 769px) and (max-width: 1024px)";
|
const TABLET_MEDIA_QUERY = "(min-width: 769px) and (max-width: 1024px)";
|
||||||
const MOBILE_WIDTH_MEDIA_QUERY = "(max-width: 768px)";
|
const MOBILE_WIDTH_MEDIA_QUERY = "(max-width: 768px)";
|
||||||
@@ -155,6 +155,33 @@ describe("useViewportMode", () => {
|
|||||||
expect(renderHook(() => useViewportMode()).result.current).toBe("mobile");
|
expect(renderHook(() => useViewportMode()).result.current).toBe("mobile");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats touch visualViewport width as mobile on folded Android panes", () => {
|
||||||
|
stubScreen(390, 844);
|
||||||
|
installViewportMedia({ width: false, height: false, tablet: true });
|
||||||
|
const originalVisualViewport = window.visualViewport;
|
||||||
|
const originalMaxTouchPoints = navigator.maxTouchPoints;
|
||||||
|
Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 1 });
|
||||||
|
Object.defineProperty(window, "visualViewport", {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
width: 390,
|
||||||
|
height: 700,
|
||||||
|
offsetTop: 0,
|
||||||
|
offsetLeft: 0,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(isMobileViewport()).toBe(true);
|
||||||
|
expect(getViewportMode()).toBe("mobile");
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(window, "visualViewport", { configurable: true, value: originalVisualViewport });
|
||||||
|
Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: originalMaxTouchPoints });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to width-only mobile detection when screen data is unavailable", () => {
|
it("falls back to width-only mobile detection when screen data is unavailable", () => {
|
||||||
stubMissingScreen();
|
stubMissingScreen();
|
||||||
installViewportMedia({ width: false, height: true, tablet: true });
|
installViewportMedia({ width: false, height: true, tablet: true });
|
||||||
|
|||||||
@@ -13,6 +13,23 @@ export const MOBILE_MEDIA_QUERY = "(max-width: 768px), (max-height: 480px)";
|
|||||||
const MOBILE_WIDTH_MEDIA_QUERY = "(max-width: 768px)";
|
const MOBILE_WIDTH_MEDIA_QUERY = "(max-width: 768px)";
|
||||||
const MOBILE_HEIGHT_MEDIA_QUERY = "(max-height: 480px)";
|
const MOBILE_HEIGHT_MEDIA_QUERY = "(max-height: 480px)";
|
||||||
|
|
||||||
|
function hasTouchScreen(): boolean {
|
||||||
|
if (typeof window === "undefined" || typeof navigator === "undefined") return false;
|
||||||
|
return "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTouchVisualViewportWidth(): number | null {
|
||||||
|
if (!hasTouchScreen()) return null;
|
||||||
|
const width = window.visualViewport?.width;
|
||||||
|
return typeof width === "number" && width > 0 ? width : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTouchVisualViewportHeight(): number | null {
|
||||||
|
if (!hasTouchScreen()) return null;
|
||||||
|
const height = window.visualViewport?.height;
|
||||||
|
return typeof height === "number" && height > 0 ? height : null;
|
||||||
|
}
|
||||||
|
|
||||||
// The virtual keyboard shrinks the CSS/visual viewport height (matching
|
// The virtual keyboard shrinks the CSS/visual viewport height (matching
|
||||||
// `(max-height: 480px)`) but never the device's physical screen. Only treat a
|
// `(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
|
// short viewport as a landscape phone when the smaller physical screen edge is
|
||||||
@@ -26,8 +43,15 @@ function isPhoneClassScreen(): boolean {
|
|||||||
|
|
||||||
export function isMobileViewport(): boolean {
|
export function isMobileViewport(): boolean {
|
||||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
|
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
|
||||||
|
const visualWidth = getTouchVisualViewportWidth();
|
||||||
|
const visualHeight = getTouchVisualViewportHeight();
|
||||||
|
/*
|
||||||
|
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 ||
|
return window.matchMedia(MOBILE_WIDTH_MEDIA_QUERY).matches ||
|
||||||
(window.matchMedia(MOBILE_HEIGHT_MEDIA_QUERY).matches && isPhoneClassScreen());
|
(visualWidth !== null && visualWidth <= 768) ||
|
||||||
|
((window.matchMedia(MOBILE_HEIGHT_MEDIA_QUERY).matches || (visualHeight !== null && visualHeight <= 480)) && isPhoneClassScreen());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getViewportMode(): ViewportMode {
|
export function getViewportMode(): ViewportMode {
|
||||||
|
|||||||
Reference in New Issue
Block a user