From 6ad61cdd1f7e3adf232079c42a1b45bd03b6182f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 18:23:48 +0200 Subject: [PATCH] fix(shell): take the window's edges and keep the document still The authenticated shell was sized by h-dvh. On iPadOS Safari that value and the area the browser lays out disagree after a rotation and under the floating toolbars: a short value left a strip of page colour under the shell with the last card cut off above it, a tall one let the document scroll by the difference and take the swipes meant for main. The shell now takes the window's edges (absolute inset-0 against the initial containing block), and the document under it gets no scroll and no rubber band (data-scroll="shell": overflow hidden, overscroll none, height 100% so the box survives a dialog's scroll lock making the body relative). Not fixed: a fixed box re-rasterises every page in a layer of its own and traps the bottom bar's z-index. The desktop shell's main now clears the home indicator of an installed app, and the page frame clears the Coach button there with pb-24: the button sits 5.5rem up, so pb-20 left the last 8 px under it. The sub-shell grid floor follows the new padding. --- e2e/settings-overscroll.spec.ts | 4 +- e2e/timeline.spec.ts | 6 +- src/__tests__/pwa-mobile-shell-guard.test.ts | 5 +- src/app/globals.css | 22 ++++ .../layout/__tests__/shell-viewport.test.ts | 119 ++++++++++++++++++ src/components/layout/auth-shell.tsx | 46 +++---- src/components/layout/document-scroll-mode.ts | 36 ++++++ src/components/layout/shell-metrics.ts | 44 ++++++- 8 files changed, 249 insertions(+), 33 deletions(-) create mode 100644 src/components/layout/__tests__/shell-viewport.test.ts create mode 100644 src/components/layout/document-scroll-mode.ts diff --git a/e2e/settings-overscroll.spec.ts b/e2e/settings-overscroll.spec.ts index 2904f7cb8..1a81e69f5 100644 --- a/e2e/settings-overscroll.spec.ts +++ b/e2e/settings-overscroll.spec.ts @@ -8,7 +8,7 @@ import { settleBeforeMeasure } from "./utils/settle"; * v1.25.11 settings fix): the scroll viewport must never extend far past * the last content element. The rule the guard pins is structural — only * the AuthShell owns the scroll height and the bottom padding - * (`pt-6 pb-20` on the wrapper, plus the ``); a page/section must never add its own viewport-height * reserve or bottom gutter, because every such nested reserve stacks on * the shell's own budget and reopens the dark-band-below-the-last-card @@ -45,7 +45,7 @@ const ROUTES = [ ] as const; const VIEWPORTS = [ - { name: "desktop", width: 1440, height: 900, expectedPad: 80 }, // wrapper pb-20 + { name: "desktop", width: 1440, height: 900, expectedPad: 96 }, // wrapper shell-desktop:pb-24 { name: "phone", width: 390, height: 844, expectedPad: 144 }, // pb-20 + main pb-16 ] as const; diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 4a3818413..8bdb4591a 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -845,9 +845,9 @@ test("the timeline page ends where its content does, at every desktop width", as expect(m.mainSw, where).toBeLessThanOrEqual(m.mainCw + 1); expect(m.docSw, where).toBeLessThanOrEqual(m.docCw + 1); expect(m.docSh, where).toBeLessThanOrEqual(m.docCh + 1); - // The shell's own buffer below the last card (pb-20 and the top - // padding), nothing more. - expect(m.mainSh, where).toBeLessThanOrEqual(m.contentBottom + 104 + 2); + // The shell's own buffer below the last card (the desktop shell's + // pb-24 and the top padding), nothing more. + expect(m.mainSh, where).toBeLessThanOrEqual(m.contentBottom + 120 + 2); } } }); diff --git a/src/__tests__/pwa-mobile-shell-guard.test.ts b/src/__tests__/pwa-mobile-shell-guard.test.ts index f756a599f..e3bc97220 100644 --- a/src/__tests__/pwa-mobile-shell-guard.test.ts +++ b/src/__tests__/pwa-mobile-shell-guard.test.ts @@ -33,7 +33,10 @@ function code(source: string): string { describe("the shell owns the safe area", () => { it("the authenticated shell wrapper takes the insets", () => { const shell = code(read("src/components/layout/auth-shell.tsx")); - expect(shell).toMatch(/className="shell-safe-area flex h-dvh flex-col"/); + expect(shell).toMatch(/
/); + expect(read("src/components/layout/shell-metrics.ts")).toMatch( + /SHELL_ROOT_CLASS =\s*"shell-safe-area absolute inset-0 flex flex-col"/, + ); }); it.each([ diff --git a/src/app/globals.css b/src/app/globals.css index ab29b8f34..5b38c8599 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -615,6 +615,28 @@ body, html[data-scroll="body"] { scrollbar-gutter: stable; } + /* + * The app shell (`data-scroll="shell"`, also set by `AuthShell`) takes the + * window's edges and scrolls inside `
`, so the document has nothing + * to scroll and must not bounce either. On iOS a swipe that reached the end + * of `
` or of the rail chained to the document, the whole page + * rubber-banded and showed the page colour under the shell, and the next + * swipe went to that bounce instead of the list under the finger: the + * "sometimes it does not scroll" a tablet reported. + * + * `height: 100%` gives the body the window's box. The shell is + * `absolute inset-0`, which resolves against the initial containing block + * while the body is static, and against the body once a dialog's scroll + * lock makes it `position: relative` (react-remove-scroll does); an + * auto-height body is 0 px tall and the shell would collapse behind every + * dialog. + */ + html[data-scroll="shell"], + html[data-scroll="shell"] body { + height: 100%; + overflow: hidden; + overscroll-behavior: none; + } body { @apply bg-background text-foreground; } diff --git a/src/components/layout/__tests__/shell-viewport.test.ts b/src/components/layout/__tests__/shell-viewport.test.ts new file mode 100644 index 000000000..d658a879d --- /dev/null +++ b/src/components/layout/__tests__/shell-viewport.test.ts @@ -0,0 +1,119 @@ +/** + * The authenticated shell's geometry decisions, which no browser under test + * can show going wrong: Playwright's WebKit and Chromium have no floating + * toolbar, no rotation and no home indicator, so `100dvh` always equals the + * window there. On an iPad it does not, and that disagreement was the strip + * under the shell and the cut-off card. These hold the decisions that take + * the disagreement out; `e2e/tablet-shell.spec.ts` measures the result. + */ +import { describe, expect, it } from "vitest"; + +import { readSource } from "@/__tests__/helpers/source-files"; + +import { + MAIN_SCROLL_CLASS, + SHELL_ROOT_CLASS, + SUB_SHELL_GRID_FLOOR, +} from "../shell-metrics"; + +const shellSource = () => + readSource("src/components/layout/auth-shell.tsx", [ + "SHELL_ROOT_CLASS", + 'data-slot="main-content-wrapper"', + ]); +/** The page frame's class list: the one `mx-auto max-w-screen-xl` literal. */ +const MAIN_CONTENT_WRAPPER_CLASS = + /"([^"]*\bmx-auto max-w-screen-xl\b[^"]*)"/.exec(shellSource())![1]; +const classes = (value: string) => value.split(/\s+/).filter(Boolean); + +/** Tailwind spacing step or `[calc(env(...)+Nrem)]` offset, in rem. */ +function rem(token: string): number { + const calc = /\+(\d+(?:\.\d+)?)rem\)\]$/.exec(token); + if (calc) return Number(calc[1]); + const step = /-(\d+(?:\.\d+)?)$/.exec(token); + if (step) return Number(step[1]) / 4; + throw new Error(`no length in ${token}`); +} + +describe("the shell takes the window's edges", () => { + it("takes its height from the initial containing block, never from a viewport unit", () => { + const shell = classes(SHELL_ROOT_CLASS); + expect(shell).toEqual( + expect.arrayContaining(["shell-safe-area", "absolute", "inset-0"]), + ); + // `fixed` re-rasterises the whole app in its own layer and traps the + // bottom bar's z-index in a stacking context. + expect(shell).not.toContain("fixed"); + expect(SHELL_ROOT_CLASS).not.toMatch( + /\b(?:min-)?h-(?:dvh|svh|lvh|screen)\b/, + ); + expect(SHELL_ROOT_CLASS).not.toMatch(/\d+(?:d|s|l)?vh/); + }); + + it("AuthShell renders that box and flags the document for it", () => { + const source = shellSource(); + expect(source).toContain("
"); + expect(source).not.toMatch(/shell-safe-area flex h-dvh/); + expect(source).toMatch( + /useDocumentScrollMode\(\s*isBodyScrolled \? "body" : rendersAppShell \? "shell" : null/, + ); + const css = readSource("src/app/globals.css", '[data-scroll="shell"]'); + expect(css).toMatch( + /html\[data-scroll="shell"\],\s*html\[data-scroll="shell"\] body\s*\{\s*height:\s*100%;\s*overflow:\s*hidden;\s*overscroll-behavior:\s*none;/, + ); + }); +}); + +describe("the bottom edge is cleared in both shells", () => { + const fab = readSource( + "src/components/insights/layout-coach-fab.tsx", + 'data-slot="coach-fab"', + ); + const fabSize = rem(/\bsize-(\d+)\b/.exec(fab)![0]); + const phoneFabBottom = rem( + /(? { + expect(main).not.toContain("shell-desktop:pb-0"); + expect(main).toContain( + "shell-desktop:pb-[env(safe-area-inset-bottom,0px)]", + ); + }); + + it("the last line scrolls clear of the Coach button in the phone shell", () => { + const mainPad = Number( + /^pb-\[calc\((\d+(?:\.\d+)?)rem\+env\(safe-area-inset-bottom,0px\)\)\]$/.exec( + main.find((c) => c.startsWith("pb-[calc("))!, + )![1], + ); + const wrapperPad = rem(wrapper.find((c) => /^pb-\d+$/.test(c))!); + expect(mainPad + wrapperPad).toBeGreaterThan(phoneFabBottom + fabSize); + }); + + it("the last line scrolls clear of the Coach button in the desktop shell", () => { + const wrapperPad = rem( + wrapper.find((c) => /^shell-desktop:pb-\d+$/.test(c))!, + ); + expect(wrapperPad).toBeGreaterThan(desktopFabBottom + fabSize); + }); + + it("the sub-shell floor follows the desktop shell's padding", () => { + // Top bar 4rem + pt-6 + the wrapper's bottom padding. + const desktopPad = rem( + wrapper.find((c) => /^shell-desktop:pb-\d+$/.test(c))!, + ); + expect(SUB_SHELL_GRID_FLOOR).toContain( + `shell-desktop:min-h-[calc(100dvh-${4 + 1.5 + desktopPad}rem)]`, + ); + }); +}); diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 18cf95207..2853585d0 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -38,6 +38,8 @@ import { } from "@/components/layout/module-page-gate"; import { cn } from "@/lib/utils"; import { isFullBleedPage } from "./full-bleed"; +import { useDocumentScrollMode } from "./document-scroll-mode"; +import { MAIN_SCROLL_CLASS, SHELL_ROOT_CLASS } from "./shell-metrics"; import { isOnboardingPathname } from "@/lib/onboarding/wizard-steps"; import { RecordScopeHydrationGate } from "./record-scope-hydration-gate"; import { SidebarNav } from "./sidebar-nav"; @@ -166,25 +168,18 @@ export function AuthShell({ const isOnboardingPage = isOnboardingPathname(pathname); const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id; - // v1.9.0 — the document-level scrollbar-gutter (globals.css) is reserved - // only for the body-scrolled shells (login / register / onboarding / - // standalone legal pages). The authenticated branch is height-locked and - // scrolls inside its own `
`, which reserves its own gutter; applying - // the document gutter there too produced a second, never-painted gutter on - // classic-scrollbar platforms. Flag the body-scrolled branches on `` - // so the CSS rule scopes to them. + // Who scrolls the page, on `` (`document-scroll-mode.ts`). const isBodyScrolled = isPublicPage || isOnboardingPage; - useEffect(() => { - const root = document.documentElement; - if (isBodyScrolled) { - root.setAttribute("data-scroll", "body"); - } else { - root.removeAttribute("data-scroll"); - } - return () => { - root.removeAttribute("data-scroll"); - }; - }, [isBodyScrolled]); + const rendersAppShell = + !isBodyScrolled && + !accessRefused && + !isLoading && + isAuthenticated && + recordSessionTransition.phase === "ready" && + !recordSessionPending; + useDocumentScrollMode( + isBodyScrolled ? "body" : rendersAppShell ? "shell" : null, + ); // v1.25.1 — the authenticated shell keeps `
` // mounted across every route change, so its scroll offset survives @@ -438,9 +433,13 @@ export function AuthShell({ five pixels of content box and pushed the logo and the account menu across its bottom border. `shell-safe-area` paints the strip behind the status bar in the chrome colour (globals.css). The bottom inset - stays with the bottom nav and `
`, which own that edge. + stays with the bottom nav, `
` and the rail, which own that edge. + + The shell takes the window's edges rather than `h-dvh`, and the + document under it neither scrolls nor bounces: `SHELL_ROOT_CLASS` in + `shell-metrics.ts` says why. */} -
+
{/* v1.4.43 — `` paints only when `navigator.onLine === false`. Sits above the maintainership @@ -500,7 +499,7 @@ export function AuthShell({ // for all of them at once instead of asking each new // absolutely-positioned child to remember its own wrapper. className={cn( - "shell-desktop:pb-0 relative flex-1 overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))]", + MAIN_SCROLL_CLASS, fullBleed ? "flex flex-col" : "[scrollbar-gutter:stable]", )} > @@ -516,7 +515,8 @@ export function AuthShell({ v1.16.8 — the bottom padding clears the always-on Coach FAB (48 px button + its offset above the bottom-nav band / the desktop bottom-6 anchor), so the last line of content - can always scroll out from under the floating button. + can always scroll out from under the floating button; the + desktop shell's sits 5.5rem up, so `pb-24` there. */}
{outsideSharedRecord ? ( diff --git a/src/components/layout/document-scroll-mode.ts b/src/components/layout/document-scroll-mode.ts new file mode 100644 index 000000000..e180ec6e9 --- /dev/null +++ b/src/components/layout/document-scroll-mode.ts @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect } from "react"; + +/** + * Who scrolls the page, published on `` as `data-scroll` for the rules + * in `globals.css`. + * + * - `"body"`: the body-scrolled shells (login / register / onboarding / + * standalone legal pages), where the document itself overflows. They get + * the document-level `scrollbar-gutter`. The authenticated branch + * scrolls inside its own `
`, which reserves its own gutter; the + * document gutter there too produced a second, never-painted gutter on + * classic-scrollbar platforms. + * - `"shell"`: the app shell. It takes the window's edges and `
` is its + * one vertical scroller, so the document gets no scroll and no rubber band + * of its own. On iOS a bounce of the document held the next swipe, and the + * list under the finger did not move. + * - `null`: anything else (the refusal panel, the loading states) keeps the + * browser's defaults. + */ +export type DocumentScrollMode = "body" | "shell" | null; + +export function useDocumentScrollMode(mode: DocumentScrollMode): void { + useEffect(() => { + const root = document.documentElement; + if (mode) { + root.setAttribute("data-scroll", mode); + } else { + root.removeAttribute("data-scroll"); + } + return () => { + root.removeAttribute("data-scroll"); + }; + }, [mode]); +} diff --git a/src/components/layout/shell-metrics.ts b/src/components/layout/shell-metrics.ts index f4378cbc3..284c56344 100644 --- a/src/components/layout/shell-metrics.ts +++ b/src/components/layout/shell-metrics.ts @@ -18,15 +18,51 @@ * never drift — Admin previously carried a stale, all-breakpoint * `min-h-[calc(100dvh-12rem)]` column reserve that this de-duplication retires. * - * Budget: top bar 4rem + the AuthShell wrapper's `pt-6` (1.5rem) + `pb-20` - * (5rem) = 10.5rem. Retune here — and only here — if those shell paddings - * change. (Latent follow-up: this constant assumes zero banners above `
`; + * Budget: top bar 4rem + the AuthShell wrapper's `pt-6` (1.5rem) + its bottom + * padding: `pb-20` (5rem) = 10.5rem in the phone shell, `pb-24` (6rem) = + * 11.5rem in the desktop shell (the page frame in `auth-shell.tsx`). Retune here — + * and only here — if those shell paddings change. (Latent follow-up: this constant assumes zero banners above `
`; * a locale/offline/demo banner or an iOS PWA safe-area inset makes it * over-reserve. A fully self-accounting `min-h-full` chain would need the * AuthShell content wrapper to pass a definite height through, which is out of * scope for the shell-only over-scroll fix.) */ -export const SUB_SHELL_GRID_FLOOR = "md:min-h-[calc(100dvh-10.5rem)]"; +export const SUB_SHELL_GRID_FLOOR = + "md:min-h-[calc(100dvh-10.5rem)] shell-desktop:min-h-[calc(100dvh-11.5rem)]"; + +/** + * The authenticated shell's outer box. It takes the window's edges + * (`absolute inset-0` against the initial containing block) rather than + * `h-dvh`, and the top and side safe-area insets (`shell-safe-area`, + * globals.css); the bottom inset belongs to whatever owns the bottom edge in + * each shell. + * + * Not a viewport unit: `h-dvh` is the browser's idea of the visible height, + * and on iPadOS Safari that idea and the area it lays out disagree after a + * rotation and under the floating toolbars. A short value left a strip of + * page colour under the shell with the last card cut off above it; a tall one + * made the document scroll by the difference, and that document scroll took + * the swipes meant for `
`. The initial containing block is the layout + * itself, recomputed with every rotation and resize, and the document never + * scrolls (`data-scroll="shell"`, `document-scroll-mode.ts`), so the box + * cannot drift from the window either. + * + * Not `fixed`: a fixed box gets a compositing layer of its own, which + * re-rasterised the text of every page (the antialiasing moved on every + * screen), and forms a stacking context that would trap the bottom bar's + * z-index under the Coach button and the toasts. + */ +export const SHELL_ROOT_CLASS = + "shell-safe-area absolute inset-0 flex flex-col"; + +/** + * `
`, the shell's one vertical scroller. Phone shell: it clears the + * bottom nav and the home indicator. Desktop shell: there is no bar, and it + * clears the home indicator of an installed app on a tablet (zero in a + * browser tab), so the last line never sits under it. + */ +export const MAIN_SCROLL_CLASS = + "relative flex-1 overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] shell-desktop:pb-[env(safe-area-inset-bottom,0px)]"; /** * The app-chrome header band: one 4rem strip whose bottom border draws the