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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 18:23:48 +02:00
1 parent 551fbbbdec
commit 6ad61cdd1f
8 files changed
+249 -33

No files matched your search

+2 -2
View File
@@ -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 `<md` bottom-nav clearance on
* (`pt-6 pb-20` on the wrapper, `pb-24` in the desktop shell, plus the `<md` bottom-nav clearance on
* `<main>`); 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;
+3 -3
View File
@@ -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);
}
}
});
+4 -1
View File
@@ -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(/<div className=\{SHELL_ROOT_CLASS\}>/);
expect(read("src/components/layout/shell-metrics.ts")).toMatch(
/SHELL_ROOT_CLASS =\s*"shell-safe-area absolute inset-0 flex flex-col"/,
);
});
it.each([
+22
View File
@@ -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 `<main>`, so the document has nothing
* to scroll and must not bounce either. On iOS a swipe that reached the end
* of `<main>` 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;
}
@@ -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("<div className={SHELL_ROOT_CLASS}>");
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(
/(?<![\w:-])bottom-\[calc\(env\(safe-area-inset-bottom,0px\)\+[\d.]+rem\)\]/.exec(
fab,
)![0],
);
const desktopFabBottom = rem(
/shell-desktop:bottom-\[calc\(env\(safe-area-inset-bottom,0px\)\+[\d.]+rem\)\]/.exec(
fab,
)![0],
);
const wrapper = classes(MAIN_CONTENT_WRAPPER_CLASS);
const main = classes(MAIN_SCROLL_CLASS);
it("the desktop shell's main keeps the home indicator off the last line", () => {
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)]`,
);
});
});
+23 -23
View File
@@ -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 `<main>`, 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 `<html>`
// so the CSS rule scopes to them.
// Who scrolls the page, on `<html>` (`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 `<main id="main-content">`
// 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 `<main>`, which own that edge.
stays with the bottom nav, `<main>` 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.
*/}
<div className="shell-safe-area flex h-dvh flex-col">
<div className={SHELL_ROOT_CLASS}>
{/*
v1.4.43 — `<OfflineBanner>` 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.
*/}
<div
data-slot="main-content-wrapper"
@@ -524,7 +524,7 @@ export function AuthShell({
className={
fullBleed
? "flex min-h-0 flex-1 flex-col"
: "mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
: "shell-desktop:pb-24 mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
}
>
{outsideSharedRecord ? (
@@ -0,0 +1,36 @@
"use client";
import { useEffect } from "react";
/**
* Who scrolls the page, published on `<html>` 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 `<main>`, 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 `<main>` 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]);
}
+40 -4
View File
@@ -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 `<main>`;
* 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 `<main>`;
* 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 `<main>`. 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";
/**
* `<main>`, 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