diff --git a/src/app/globals.css b/src/app/globals.css index 558d1b8..2e32973 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -51,6 +51,8 @@ } html { + height: 100%; + overflow: hidden; overscroll-behavior: none; } @@ -59,6 +61,18 @@ body { -moz-osx-font-smoothing: grayscale; } +/* + * Keep the application attached to Safari's current visible viewport. A + * percentage height can retain the old layout viewport after an iOS tab is + * restored, leaving the normal-flow mobile navigation above the bottom edge. + */ +.app-viewport { + height: 100vh; + height: 100dvh; + min-height: 0; + overflow: hidden; +} + button, a, input, @@ -128,6 +142,6 @@ html[data-reader-width="wide"] .mail-reader-container { } @media print { - html, body { height: auto !important; overflow: visible !important; } + html, body, .app-viewport { height: auto !important; overflow: visible !important; } body > div, main { height: auto !important; overflow: visible !important; } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 97e0f70..2936145 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -56,8 +56,12 @@ export default async function RootLayout({ const session = await auth(); return ( - - + + @@ -128,7 +132,7 @@ export default async function RootLayout({ {/* Mobile bottom nav — hidden on desktop */} {session && ( -
+
)} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 573a8c3..e1774ad 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -2,7 +2,7 @@ import { signIn } from "@/auth"; export default function LoginPage() { return ( -
+
diff --git a/src/app/smoke-tests/SmokeHarness.tsx b/src/app/smoke-tests/SmokeHarness.tsx index 152b17b..d72024a 100644 --- a/src/app/smoke-tests/SmokeHarness.tsx +++ b/src/app/smoke-tests/SmokeHarness.tsx @@ -6,6 +6,7 @@ import AttachmentList from "@/components/AttachmentList"; import Composer from "@/components/Composer"; import EmailListPanel from "@/components/EmailListPanel"; import MessageActionBar from "@/components/MessageActionBar"; +import MobileNav from "@/components/MobileNav"; import { useUnreadCount } from "@/components/UnreadCountProvider"; import { DEFAULT_FAVICON_HREF, @@ -24,7 +25,8 @@ export type SmokePanel = | "auto-sync" | "dark-rendering" | "tab-indicator" - | "message-actions"; + | "message-actions" + | "mobile-viewport"; const fixtureEmails: Email[] = [ { @@ -343,6 +345,20 @@ export default function SmokeHarness({ panel }: { panel: SmokePanel }) { )} + {panel === "mobile-viewport" && ( +
+
+

+ The navigation stays attached to the live mobile viewport. +

+
+ +
+ )} + {panel === "target" && (

diff --git a/src/app/smoke-tests/page.tsx b/src/app/smoke-tests/page.tsx index cf0846f..7f33187 100644 --- a/src/app/smoke-tests/page.tsx +++ b/src/app/smoke-tests/page.tsx @@ -14,6 +14,7 @@ const smokePanels = new Set([ "dark-rendering", "tab-indicator", "message-actions", + "mobile-viewport", ]); export default async function SmokePage({ searchParams }: Props) { diff --git a/tests/smoke/mail.spec.ts b/tests/smoke/mail.spec.ts index 41f6faf..6a9c961 100644 --- a/tests/smoke/mail.spec.ts +++ b/tests/smoke/mail.spec.ts @@ -232,6 +232,37 @@ test("aligns message actions at the iPhone 15 Pro viewport", async ({ ).toBe(true); }); +test("keeps the mobile navigation attached after viewport height changes", async ({ + page, +}) => { + async function expectNavigationAtViewportBottom(expectedHeight: number) { + const geometry = await page + .getByRole("navigation", { name: "Primary navigation" }) + .evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + bottom: rect.bottom, + bodyHeight: document.body.getBoundingClientRect().height, + scrollY: window.scrollY, + }; + }); + + expect(geometry.bottom).toBeCloseTo(expectedHeight, 0); + expect(geometry.bodyHeight).toBeCloseTo(expectedHeight, 0); + expect(geometry.scrollY).toBe(0); + } + + await page.setViewportSize({ width: 393, height: 852 }); + await page.goto("/smoke-tests?panel=mobile-viewport"); + await expectNavigationAtViewportBottom(852); + + await page.setViewportSize({ width: 393, height: 690 }); + await expectNavigationAtViewportBottom(690); + + await page.setViewportSize({ width: 393, height: 852 }); + await expectNavigationAtViewportBottom(852); +}); + test("moves through conversations with the keyboard", async ({ page }) => { await page.goto("/smoke-tests");