Files
fusion/packages/dashboard/app/hooks/useViewportMode.ts
gsxdsm 02ae5a9340 FN-6213: prevent keyboard from forcing mobile layout
Keep tablet and desktop layouts stable when the virtual keyboard shrinks viewport height.

- Gate short-height mobile detection on phone-class physical screen size.
- Reuse the shared mobile viewport helper across board, terminal, and workflow editor layout decisions.
- Cover tablet keyboard and landscape phone viewport behavior with regression tests and document the UI bug pattern.
- Preserve quarantine exclusions while resolving the squash merge conflict.

Files changed:
 .../ui-bugs/tablet-keyboard-viewport-mode-flip.md  | 54 +++++++++++++
 packages/dashboard/app/components/Board.tsx        |  4 +-
 .../dashboard/app/components/SessionTerminal.tsx   | 16 +---
 .../app/components/WorkflowNodeEditor.tsx          | 41 +++++-----
 .../__tests__/SessionTerminal.mobile.test.tsx      | 57 ++++++++++----
 .../app/hooks/__tests__/useViewportMode.test.ts    | 90 +++++++++++++++++++++-
 packages/dashboard/app/hooks/useViewportMode.ts    | 26 ++++++-
 packages/engine/vitest.config.ts                   |  1 +
 scripts/lib/test-quarantine.json                   |  5 ++
 9 files changed, 239 insertions(+), 55 deletions(-)

Fusion-Task-Id: FN-6213

Fusion-Task-Lineage: 01af6489-8c34-4318-9cff-1bceb1fa5a5d
2026-06-11 19:37:25 -07:00

96 lines
3.7 KiB
TypeScript

import { useState, useEffect } from "react";
export type ViewportMode = "mobile" | "tablet" | "desktop";
// `(max-height: 480px)` catches phones held in landscape, which can exceed
// 768 CSS px wide but stay short. Without it, landscape phones fall out of
// mobile mode and lose the bottom nav bar + get the desktop horizontally-
// scrollable board. Runtime mode resolution additionally gates this height
// clause on phone-class physical screen size so virtual keyboards cannot flip
// tablet/desktop layouts into mobile mode by shrinking the CSS viewport height.
export const MOBILE_MEDIA_QUERY = "(max-width: 768px), (max-height: 480px)";
const MOBILE_WIDTH_MEDIA_QUERY = "(max-width: 768px)";
const MOBILE_HEIGHT_MEDIA_QUERY = "(max-height: 480px)";
// The virtual keyboard shrinks the CSS/visual viewport height (matching
// `(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 {
if (typeof window === "undefined" || !window.screen) return false;
const { width, height } = window.screen;
if (!width || !height) return false;
return Math.min(width, height) <= 480;
}
export function isMobileViewport(): boolean {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
return window.matchMedia(MOBILE_WIDTH_MEDIA_QUERY).matches ||
(window.matchMedia(MOBILE_HEIGHT_MEDIA_QUERY).matches && 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";
return "desktop";
}
export function useViewportMode(): ViewportMode {
const [mode, setMode] = useState<ViewportMode>(getViewportMode);
useEffect(() => {
if (typeof window === "undefined") return;
const mobileQuery = window.matchMedia(MOBILE_MEDIA_QUERY);
const tabletQuery = window.matchMedia("(min-width: 769px) and (max-width: 1024px)");
const updateMode = () => {
setMode(getViewportMode());
};
const addChangeListener = (query: MediaQueryList, listener: () => void) => {
if (typeof query.addEventListener === "function") {
query.addEventListener("change", listener);
return;
}
if (typeof query.addListener === "function") {
query.addListener(listener);
}
};
const removeChangeListener = (query: MediaQueryList, listener: () => void) => {
if (typeof query.removeEventListener === "function") {
query.removeEventListener("change", listener);
return;
}
if (typeof query.removeListener === "function") {
query.removeListener(listener);
}
};
addChangeListener(mobileQuery, updateMode);
addChangeListener(tabletQuery, updateMode);
window.addEventListener("resize", updateMode);
window.addEventListener("orientationchange", updateMode);
const visualViewport = window.visualViewport;
if (typeof visualViewport?.addEventListener === "function") {
visualViewport.addEventListener("resize", updateMode);
}
updateMode();
return () => {
removeChangeListener(mobileQuery, updateMode);
removeChangeListener(tabletQuery, updateMode);
window.removeEventListener("resize", updateMode);
window.removeEventListener("orientationchange", updateMode);
if (typeof visualViewport?.removeEventListener === "function") {
visualViewport.removeEventListener("resize", updateMode);
}
};
}, []);
return mode;
}