fix(viewport): keep large phones in mobile mode so the bottom nav renders

FN-8557 excluded any touch device from mobile mode when its reported physical
screen exceeded the 480px phone threshold, at any CSS width. Large Android
phones report exactly that, so they resolved to tablet: MobileNavBar returns
null off mode "mobile", while MobileNavBar.css still displayed at
(max-width: 768px) — JS and CSS disagreeing about the same device. The board
also fell back to the horizontally-scrollable desktop layout.

No tablet uses a <=600px viewport as its primary layout, so width now
overrides the physical-screen heuristic below that floor; the carve-out
FN-8557 wanted (portrait tablets at the 768 boundary) keeps the 601-768 band.

The floor checks all three width signals the classifier already trusts —
media query, innerWidth, visualViewport — since each can lead on a different
device or update late on rotation. Regression tests cover all three; each
fails against the pre-fix classifier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-24 18:54:27 -07:00
parent 749167cbed
commit c50e303a94
3 changed files with 137 additions and 1 deletions

View File

@@ -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.

View File

@@ -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);

View File

@@ -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());
}