diff --git a/.changeset/mobile-nav-large-phones.md b/.changeset/mobile-nav-large-phones.md new file mode 100644 index 0000000000..c1b5eda72b --- /dev/null +++ b/.changeset/mobile-nav-large-phones.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Restore the mobile bottom nav bar on large phones, which were being treated as tablets. +category: fix +dev: `isMobileViewport()` gained a phone width floor (`PHONE_MAX_CSS_WIDTH` = 600) that overrides the FN-8557 `isTabletClassTouchScreen()` exclusion. That check treats any touch device whose `window.screen` min edge exceeds 480px as tablet-class, which large Android phones report, so they lost mobile mode at any CSS width while `MobileNavBar.css` still displayed at `(max-width: 768px)`. The tablet carve-out now applies only in the 601-768px band. diff --git a/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts b/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts index 29b4445e66..fe00ab5024 100644 --- a/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts @@ -5,6 +5,7 @@ import { getViewportMode, isFullScreenSheetViewport, isMobileViewport, MOBILE_ME const TABLET_MEDIA_QUERY = "(min-width: 769px) and (max-width: 1024px)"; const MOBILE_WIDTH_MEDIA_QUERY = "(max-width: 768px)"; const MOBILE_HEIGHT_MEDIA_QUERY = "(max-height: 480px)"; +const PHONE_WIDTH_MEDIA_QUERY = "(max-width: 600px)"; const originalScreenDescriptor = Object.getOwnPropertyDescriptor(window, "screen"); function stubScreen(width: number, height: number) { @@ -150,6 +151,97 @@ describe("useViewportMode", () => { expect(renderHook(() => useViewportMode()).result.current).toBe("tablet"); }); + /* + FNXC:ViewportMode 2026-07-24-18:55: + Regression cover for large Android phones losing the bottom nav bar. `window.screen` + is not a reliable phone/tablet discriminator — these devices report a screen min edge + above the 480px phone threshold, so the tablet-class exclusion stripped mobile mode at + any CSS width. The invariant is width-based and asserted across every width signal the + classifier reads (media query, innerWidth, visualViewport), not just the reported case, + because each of the three can lead on a different device or update late on rotation. + */ + describe("phone width floor overrides physical-screen tablet classification", () => { + const LARGE_PHONE_SCREEN: [number, number] = [1080, 2400]; + + function withTouch(run: () => void) { + const originalMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, "maxTouchPoints"); + Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 5 }); + try { + run(); + } finally { + if (originalMaxTouchPoints) Object.defineProperty(navigator, "maxTouchPoints", originalMaxTouchPoints); + } + } + + function stubPhoneWidthMedia(phoneWidthMatches: boolean) { + vi.stubGlobal( + "matchMedia", + vi.fn((query: string) => ({ + matches: + query === MOBILE_MEDIA_QUERY || query === MOBILE_WIDTH_MEDIA_QUERY + ? true + : query === PHONE_WIDTH_MEDIA_QUERY + ? phoneWidthMatches + : false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + } + + it("stays mobile when the phone width media query matches", () => { + withTouch(() => { + stubScreen(...LARGE_PHONE_SCREEN); + stubPhoneWidthMedia(true); + + expect(isMobileViewport()).toBe(true); + expect(getViewportMode()).toBe("mobile"); + }); + }); + + it("stays mobile on a narrow innerWidth even when the media query has not updated", () => { + withTouch(() => { + stubScreen(...LARGE_PHONE_SCREEN); + stubPhoneWidthMedia(false); + vi.stubGlobal("innerWidth", 430); + + expect(isMobileViewport()).toBe(true); + expect(getViewportMode()).toBe("mobile"); + }); + }); + + it("stays mobile on a narrow visualViewport pane", () => { + withTouch(() => { + stubScreen(...LARGE_PHONE_SCREEN); + stubPhoneWidthMedia(false); + const originalVisualViewport = window.visualViewport; + Object.defineProperty(window, "visualViewport", { + configurable: true, + value: { width: 412, height: 915, addEventListener: vi.fn(), removeEventListener: vi.fn() }, + }); + + try { + expect(isMobileViewport()).toBe(true); + expect(getViewportMode()).toBe("mobile"); + } finally { + Object.defineProperty(window, "visualViewport", { configurable: true, value: originalVisualViewport }); + } + }); + }); + + it("leaves the 601-768px tablet band to the physical-screen classification", () => { + withTouch(() => { + stubScreen(768, 1024); + stubPhoneWidthMedia(false); + vi.stubGlobal("innerWidth", 700); + + expect(isMobileViewport()).toBe(false); + }); + }); + }); + it("keeps a touch tablet at the 768px boundary out of the phone presentation", () => { const originalMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, "maxTouchPoints"); stubScreen(768, 1024); diff --git a/packages/dashboard/app/hooks/useViewportMode.ts b/packages/dashboard/app/hooks/useViewportMode.ts index 92c9da172b..415885a354 100644 --- a/packages/dashboard/app/hooks/useViewportMode.ts +++ b/packages/dashboard/app/hooks/useViewportMode.ts @@ -13,6 +13,19 @@ 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)"; +/* +FNXC:ViewportMode 2026-07-24-18:55: +No tablet renders a viewport this narrow as its primary layout, so a CSS width at +or below this is a phone regardless of what `window.screen` reports. The physical- +screen heuristic below cannot be trusted on its own: large Android phones report a +screen min edge above the 480px phone threshold, which classified them tablet-class +and dropped the bottom nav bar entirely while `MobileNavBar.css` still displayed at +`(max-width: 768px)` — JS and CSS disagreeing about the same device. The tablet +carve-out only ever needed the 601-768px band (portrait tablets at the boundary). +*/ +const PHONE_WIDTH_MEDIA_QUERY = "(max-width: 600px)"; +const PHONE_MAX_CSS_WIDTH = 600; + /* FNXC:ModalGeometryPersistence 2026-07-15-19:30: Full-screen FloatingWindow sheets use only the CSS width breakpoint. This deliberately diverges from @@ -64,6 +77,23 @@ function isTabletClassTouchScreen(): boolean { return hasTouchScreen() && hasKnownPhysicalScreenSize() && !isPhoneClassScreen(); } +/** + * Whether the CSS viewport is narrow enough to be a phone on width alone. + * + * FNXC:ViewportMode 2026-07-24-18:55: + * Overrides the physical-screen tablet classification: no `window.screen` value + * can turn a <=600px CSS viewport into a tablet presentation. Checks every width + * signal the module already trusts (visual viewport, media query, innerWidth) so + * a delayed media-query update on rotation cannot strand a phone in tablet mode. + */ +function isPhoneClassWidth(): boolean { + if (typeof window === "undefined") return false; + const visualWidth = getTouchVisualViewportWidth(); + if (visualWidth !== null && visualWidth <= PHONE_MAX_CSS_WIDTH) return true; + if (typeof window.matchMedia === "function" && window.matchMedia(PHONE_WIDTH_MEDIA_QUERY).matches) return true; + return window.innerWidth > 0 && window.innerWidth <= PHONE_MAX_CSS_WIDTH; +} + export function isMobileViewport(): boolean { if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false; const visualWidth = getTouchVisualViewportWidth(); @@ -82,7 +112,14 @@ export function isMobileViewport(): boolean { const hasNarrowWidth = window.innerWidth <= 768 || window.matchMedia(MOBILE_WIDTH_MEDIA_QUERY).matches || (visualWidth !== null && visualWidth <= 768); - return (hasNarrowWidth && !isTabletClassTouchScreen()) || + /* + FNXC:ViewportMode 2026-07-24-18:55: + The tablet exclusion applies only above the phone width floor. Without that + guard every touch device whose reported physical screen exceeds the 480px phone + threshold — which includes large Android phones — lost mobile mode at any width, + taking the bottom nav bar with it. + */ + return (hasNarrowWidth && (isPhoneClassWidth() || !isTabletClassTouchScreen())) || ((window.matchMedia(MOBILE_HEIGHT_MEDIA_QUERY).matches || (visualHeight !== null && visualHeight <= 480)) && isPhoneClassScreen()); }