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 1/5] 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 From 1f4c1bd2f04245c06d0f0a230deff0bd18134085 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 2/5] fix(rail): keep every destination reachable under a finger A touch tablet keeps the rail's 44 px rows, so in landscape three to seven destinations sit below the fold, and the list has no scrollbar at rest. The edge fade alone did not read as "swipe here", and a swipe that reached an end chained to the document, which held the next one. The list region moves into its own module. Under a coarse pointer it contains its overscroll, keeps a focused row clear of a new scroll cue, and shows that cue at an edge with more destinations; a tap scrolls the list most of a screen on. The cue stays out of the focus order and the accessibility tree: the keyboard and screen readers already reach every row. The rail takes the home indicator's inset so the account row stays clear of it. A mouse sees no change. --- .../layout/__tests__/sidebar-nav.test.tsx | 62 ++++++ src/components/layout/sidebar-nav-scroll.tsx | 192 ++++++++++++++++++ src/components/layout/sidebar-nav.tsx | 103 ++-------- 3 files changed, 268 insertions(+), 89 deletions(-) create mode 100644 src/components/layout/sidebar-nav-scroll.tsx diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx index 88809bc95..b123458a2 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -63,6 +63,7 @@ import { readSidebarCollapsedPref, writeSidebarCollapsedPref, } from "../sidebar-nav"; +import { RAIL_NAV_SCROLL, RailScrollCue } from "../sidebar-nav-scroll"; import { ADMIN_SECTIONS } from "@/components/admin/admin-shell"; import { visibleUtilityDestinations } from "../nav-model"; import { delegatedDomains } from "@/lib/sharing/domain-write-support"; @@ -577,3 +578,64 @@ describe(" carries no rules for a viewport it never shows on", () => expect(source).not.toMatch(/pointer:coarse\)_and_\(height<=520px\)/); }); }); + +describe(" keeps every destination reachable on a touch tablet", () => { + function navClass(html: string): string { + return ( + /]*aria-label="[^"]*"[^>]*class="([^"]*)"/.exec(html)?.[1] ?? "" + ); + } + + it("scrolls the list inside the rail and keeps the swipe there", () => { + const nav = navClass(render()).replaceAll("&", "&").split(" "); + expect(RAIL_NAV_SCROLL.split(" ")).toEqual( + expect.arrayContaining(["min-h-0", "flex-1", "overflow-y-auto"]), + ); + for (const cls of RAIL_NAV_SCROLL.split(" ")) expect(nav).toContain(cls); + // Without it a swipe at either end chained to the document on iOS. + expect(nav).toContain("pointer-coarse:overscroll-contain"); + // A focused row lands clear of the 44 px cue under a finger. + expect(nav).toContain("pointer-coarse:scroll-py-12"); + }); + + it("gives the rail the home indicator's inset, so the avatar row stays clear", () => { + const aside = /]*class="([^"]*)"/.exec(render())?.[1] ?? ""; + expect(aside.split(" ")).toContain("pb-[env(safe-area-inset-bottom,0px)]"); + }); + + it("draws the scroll cue for a finger only, outside the focus order and the accessibility tree", () => { + for (const direction of ["up", "down"] as const) { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('data-slot="sidebar-nav-scroll-cue"'); + expect(html).toContain(`data-direction="${direction}"`); + expect(html).toContain('tabindex="-1"'); + expect(html).toContain('aria-hidden="true"'); + const cls = /class="([^"]*)"/.exec(html)![1].split(" "); + expect(cls).toEqual( + expect.arrayContaining(["hidden", "pointer-coarse:flex"]), + ); + // 44 px touch floor. + expect(cls).toContain("h-11"); + } + }); + + it("scrolls the list most of a screen on per tap", () => { + const calls: ScrollToOptions[] = []; + const nav = { + clientHeight: 400, + scrollBy: (o: ScrollToOptions) => calls.push(o), + } as unknown as HTMLElement; + vi.stubGlobal("window", { + matchMedia: () => ({ matches: true }), + }); + const element = RailScrollCue({ + direction: "down", + navRef: { current: nav }, + }) as React.ReactElement<{ onClick: () => void }>; + element.props.onClick(); + vi.unstubAllGlobals(); + expect(calls).toEqual([{ top: 300, behavior: "auto" }]); + }); +}); diff --git a/src/components/layout/sidebar-nav-scroll.tsx b/src/components/layout/sidebar-nav-scroll.tsx new file mode 100644 index 000000000..262d75a44 --- /dev/null +++ b/src/components/layout/sidebar-nav-scroll.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { ChevronDown, ChevronUp } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +/** + * The rail's destination list as a scroll region: the one part of the rail + * that gives up height, between the pinned logo band above and the pinned + * footer group and account row below (`sidebar-nav.tsx`). + */ + +/** + * The nav list's scroll region. `scroll-py-6` keeps a focused row clear of + * the edge fades; under a finger `scroll-py-12` keeps it clear of the scroll + * cue as well, which is 44 px tall. `overscroll-contain` is the own-scroll + * list rule (ui-standards §2), taken under a finger, where the swipe that + * reaches an end is the problem. A mouse or trackpad has nothing to chain + * to (the app shell's document neither scrolls nor bounces), and on Chromium + * the property moves the list into a layer of its own, which re-rasterised + * every label in the rail by a shade. + */ +export const RAIL_NAV_SCROLL = + "min-h-0 flex-1 scroll-py-6 pointer-coarse:scroll-py-12 overflow-y-auto pointer-coarse:overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"; + +/** + * The scroll cue at an edge of the nav list that has more destinations + * beyond it, under a finger only. + * + * A touch tablet keeps the 44 px rows, so in landscape (an iPad is 670 to + * 830 px tall in Safari) three to seven destinations sit below the fold, and + * the list has no scrollbar: iOS paints none on an element at rest. The fade + * alone did not read as "swipe here", and the rail looked as if it simply + * ended. The cue says so and is a way there in itself: a tap scrolls the + * list most of a screen on. + * + * It is a pointer shortcut and nothing else. The keyboard reaches every row + * by Tab (focus scrolls the row into view) and a screen reader reads the + * list whole, so the cue stays out of the focus order and the accessibility + * tree. A mouse scrolls with the wheel and never sees it. + */ +export function RailScrollCue({ + direction, + navRef, +}: { + direction: "up" | "down"; + navRef: React.RefObject; +}) { + const Icon = direction === "down" ? ChevronDown : ChevronUp; + return ( + + ); +} + +/** Height of the fade at a scroll edge that has more content beyond it. */ +const RAIL_FADE = "1.5rem"; + +/** + * The fade mask for the list's scroll edges: a top fade while there is + * content above, a bottom fade while there is content below. A mask paints + * alpha only, so it reads the same in light and dark and never moves layout. + */ +function railFadeMask( + top: boolean, + bottom: boolean, +): React.CSSProperties | undefined { + if (!top && !bottom) return undefined; + const image = `linear-gradient(to bottom, ${top ? "transparent" : "#000"} 0, #000 ${top ? RAIL_FADE : "0px"}, #000 calc(100% - ${bottom ? RAIL_FADE : "0px"}), ${bottom ? "transparent" : "#000"} 100%)`; + return { maskImage: image, WebkitMaskImage: image }; +} + +/** + * Whether the nav list is taller than the room the rail leaves it, i.e. + * whether it scrolls, and which of its edges have more content beyond them. + * Re-measured when the nav (viewport height) or the list (entries arriving + * as modules resolve) changes size, and on scroll. + */ +export function useNavOverflows(navRef: React.RefObject): { + overflows: boolean; + moreAbove: boolean; + moreBelow: boolean; +} { + const [edges, setEdges] = useState({ + overflows: false, + moreAbove: false, + moreBelow: false, + }); + useEffect(() => { + const nav = navRef.current; + if (!nav || typeof ResizeObserver === "undefined") return; + // Strict: any overflow, the same test that makes the list scroll. + const update = () => { + const overflows = nav.scrollHeight > nav.clientHeight; + const moreAbove = overflows && nav.scrollTop > 0.5; + const moreBelow = + overflows && nav.scrollTop + nav.clientHeight < nav.scrollHeight - 0.5; + setEdges((prev) => + prev.overflows === overflows && + prev.moreAbove === moreAbove && + prev.moreBelow === moreBelow + ? prev + : { overflows, moreAbove, moreBelow }, + ); + }; + const observer = new ResizeObserver(update); + observer.observe(nav); + if (nav.firstElementChild) observer.observe(nav.firstElementChild); + nav.addEventListener("scroll", update, { passive: true }); + update(); + return () => { + observer.disconnect(); + nav.removeEventListener("scroll", update); + }; + }, [navRef]); + return edges; +} + +/** + * The list's region: the `
- + {/* Bottom utility links — the shared utility tail (minus Notifications, which lives in the avatar menu) with the From 22976337cbe44b71adf148735a93b32b0d3d77d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 18:23:49 +0200 Subject: [PATCH 3/5] fix(tabs): leave the vertical pan to the page touch-pan-x on the tab strip refused a vertical pan that began on it, so a swipe up or down over a tab strip scrolled nothing. The strip already pans sideways through its own overflow. --- src/components/ui/__tests__/tabs.test.tsx | 17 +++++++++++++++++ src/components/ui/tabs.tsx | 11 ++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/components/ui/__tests__/tabs.test.tsx b/src/components/ui/__tests__/tabs.test.tsx index 785c49966..c3c3ccb1a 100644 --- a/src/components/ui/__tests__/tabs.test.tsx +++ b/src/components/ui/__tests__/tabs.test.tsx @@ -30,4 +30,21 @@ describe("", () => { // Horizontal scroll behaviour is still intact for long strips. expect(list![0]).toMatch(/\boverflow-x-auto\b/); }); + + it("leaves the vertical pan to the page", () => { + // `touch-pan-x` (or `touch-none`) on the strip refuses a vertical pan + // that starts on it, so a swipe up or down over a tab strip scrolled + // nothing. The strip pans sideways through its own overflow. + const html = renderToStaticMarkup( + + + A + + , + ); + const list = html.match(/]*data-slot="tabs-list"[^>]*>/)![0]; + expect(list).not.toMatch( + /\btouch-(?:pan-x|pan-left|pan-right|none|pinch-zoom)\b/, + ); + }); }); diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index 1084d57be..7a2e1a78b 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -29,8 +29,8 @@ function Tabs({ // mobile when more than ~3 tabs render at typical label widths // (`/insights`, `/admin`, `/settings/*`). Tab labels are i18n-driven // so we cannot truncate them deterministically. Instead, the list -// becomes horizontally scrollable on mobile via `overflow-x-auto` -// plus `touch-pan-x` for the iOS PWA, and the wrapping tabs preserve +// becomes horizontally scrollable on mobile via `overflow-x-auto`, and the +// wrapping tabs preserve // `whitespace-nowrap` so labels never word-wrap mid-tab. Desktop // looks identical because the inline-flex shrink-wraps inside the // wider container. @@ -47,10 +47,15 @@ function Tabs({ // Padding sits at `p-1` (4 px) so the 3 px focus-visible ring // (`focus-visible:ring-[3px]`) on triggers stays inside the // `overflow-y-hidden` clamp without clipping the bottom of the ring. +// No `touch-action` on the strip: `overflow-x-auto` already pans it sideways +// under a finger, and the `touch-pan-x` it once carried also refused the +// vertical pan, so a swipe up or down that began on a tab strip scrolled +// nothing at all (on a tablet the strip is 52 px of the page under the 44 px +// touch floor). // Under a coarse pointer the strip sizes to its triggers, which take the // 44 px touch floor; a mouse keeps the compact 36 px strip. const tabsListVariants = cva( - "rounded-lg p-1 group-data-[orientation=horizontal]/tabs:h-9 pointer-coarse:group-data-[orientation=horizontal]/tabs:h-auto data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit max-w-full items-center justify-center overflow-x-auto overflow-y-hidden touch-pan-x scrollbar-thin group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col group-data-[orientation=vertical]/tabs:overflow-x-visible group-data-[orientation=vertical]/tabs:overflow-y-visible", + "rounded-lg p-1 group-data-[orientation=horizontal]/tabs:h-9 pointer-coarse:group-data-[orientation=horizontal]/tabs:h-auto data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit max-w-full items-center justify-center overflow-x-auto overflow-y-hidden scrollbar-thin group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col group-data-[orientation=vertical]/tabs:overflow-x-visible group-data-[orientation=vertical]/tabs:overflow-y-visible", { variants: { variant: { From 798747f987ba48aef514d132eed9bada8644dbab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 18:23:49 +0200 Subject: [PATCH 4/5] test(e2e): walk the app shell at every tablet size tablet-shell.spec.ts runs on WebKit with an iPad profile (webkit-tablet, in the WebKit job) and on Chromium with an Android tablet profile (chromium-tablet, in the shards), across iPad mini to iPad Pro 13 in both orientations, Safari's bars, Split View and Slide Over. It checks that the shell fills the window and leaves the document nothing to scroll, that every rail destination scrolls into view on top and takes a tap, that the scroll cue follows the overflow, that main reaches its last line clear of the bottom bar, that the page scrolls again after a sheet and a dialog close, and that a tab strip leaves the vertical pan to the page. ui-standards records the shell and rail rules. --- .github/workflows/e2e.yml | 9 +- docs/contributing/ui-standards.md | 8 +- e2e/tablet-shell.spec.ts | 420 ++++++++++++++++++++++++++++++ playwright.config.ts | 25 ++ 4 files changed, 455 insertions(+), 7 deletions(-) create mode 100644 e2e/tablet-shell.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 3710d15fc..165aca665 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -62,7 +62,7 @@ jobs: AUTH_RP_ORIGIN: http://localhost:3000 SESSION_COOKIE_SECURE: "false" NODE_ENV: production - # The WebKit spec runs in the `webkit` job below. + # The WebKit specs run in the `webkit` job below. E2E_SKIP_WEBKIT: "1" steps: @@ -146,8 +146,9 @@ jobs: if [ -z "$version" ]; then echo "playwright-core not found in pnpm-lock.yaml"; exit 1; fi echo "version=$version" >> "$GITHUB_OUTPUT" - # The WebKit spec (`bottom-sheet.spec.ts`, project `webkit-iphone`), in the - # same image as the shards. + # The WebKit specs, in the same image as the shards: the bottom sheet on an + # iPhone (`bottom-sheet.spec.ts`, project `webkit-iphone`) and the app shell + # on an iPad (`tablet-shell.spec.ts`, project `webkit-tablet`). webkit: runs-on: ubuntu-24.04 needs: playwright-version @@ -202,7 +203,7 @@ jobs: - run: NODE_OPTIONS=--max-old-space-size=8192 pnpm build - - run: pnpm exec playwright test --project=webkit-iphone + - run: pnpm exec playwright test --project=webkit-iphone --project=webkit-tablet env: E2E_BASE_URL: http://localhost:3000 diff --git a/docs/contributing/ui-standards.md b/docs/contributing/ui-standards.md index fe3074434..ffed43557 100644 --- a/docs/contributing/ui-standards.md +++ b/docs/contributing/ui-standards.md @@ -32,10 +32,10 @@ The gaps this section used to list are closed: `TileHeader` carries a `size="sm" ## 2. Spacing scale -> **Scroll height is shell-owned.** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper + ``). Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `
`; nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`. +> **Scroll height is shell-owned.** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper, `pb-24` in the desktop shell, + the phone shell's bottom-nav clearance on `
`). The shell itself takes the window's edges (`absolute inset-0`), never `h-dvh`, and `
` is the one vertical scroller: the document neither scrolls nor bounces under it. Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `
`; nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`. - **Allowed steps:** `1 / 1.5 / 2 / 2.5 / 3 / 3.5 / 4 / 6 / 8 / 10`. `2.5` and `3.5` are allowed: the medication card's rhythm is built on them, and the in-between steps earn their keep in dense card interiors. `5` (20 px), as in `py-5` or `p-5`, is not allowed; replace it when you touch a file that still has it. `7` (28 px) is not allowed either: it was the old Settings and Admin `pl-7` body gutter, which is gone (§13). -- **Page frame:** exactly one authenticated container, owned by `AuthShell` (`layout/auth-shell.tsx:318`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6`. Never re-implement it in a page; full-bleed pages opt out via a shell flag, not negative margins. Prose pages (about/privacy/legal): `mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12`. +- **Page frame:** exactly one authenticated container, owned by `AuthShell` (`MAIN_CONTENT_WRAPPER_CLASS` in `layout/shell-metrics.ts`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6 shell-desktop:pb-24`. Never re-implement it in a page; full-bleed pages opt out via a shell flag, not negative margins. Prose pages (about/privacy/legal): `mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12`. - **Page rhythm:** root of every page is `space-y-6`. Grouped card sections: `space-y-10` between groups, `space-y-4` inside a group (notifications/integrations pattern). Insights sub-pages join `space-y-6`. - **Left reading edge:** page-level text (H1, explainer, section headings) sits at the container edge. Only surfaces that paint a visible border/background may inset their content (`px-4 md:px-6`). A block without a border must not carry horizontal padding. - **Grids:** grids **of** cards/tiles = `gap-4`; value read-outs **inside** one card = `gap-x-3 gap-y-2`; form field grids = `gap-4`. @@ -148,7 +148,7 @@ Forbidden on any Insights card: a hand-rolled icon+title row, a card title centr ## 9. One scroll floor; sub-shells never re-declare bottom-space -Reinforces §2's "scroll height is shell-owned". Only `AuthShell` owns the scroll container's bottom padding and any viewport-height reserve: the `
` clears the mobile bottom-nav (`pb-[calc(4rem+safe-area)]`) and its wrapper clears the Coach FAB (`pb-20`). **No page, section, or sub-shell** adds a `min-h-[calc(100dvh-…)]` / `min-h-dvh` reserve or a `pb-*` bottom gutter inside the scrolling `
`; a nested reserve stacks on the shell budget and scrolls past the last card. +Reinforces §2's "scroll height is shell-owned". Only `AuthShell` owns the scroll container's bottom padding and any viewport-height reserve: the `
` clears the mobile bottom-nav (`pb-[calc(4rem+safe-area)]`), or the home indicator in the desktop shell, and its wrapper clears the Coach FAB (`pb-20`, `pb-24` in the desktop shell). **No page, section, or sub-shell** adds a `min-h-[calc(100dvh-…)]` / `min-h-dvh` reserve or a `pb-*` bottom gutter inside the scrolling `
`; a nested reserve stacks on the shell budget and scrolls past the last card. A two-column sub-shell that needs a "don't jump while loading" floor derives it from **one shared source**: `SUB_SHELL_GRID_FLOOR` (`src/components/layout/shell-metrics.ts`); applied to the **grid** (not a column), so the `1fr` content row stretches to fill without over-shooting. It never re-declares the floor per-shell (`AdminShell` drifting to a stale 12rem column reserve is exactly how this recurred). Guard: `e2e/settings-overscroll.spec.ts` covers Settings **and** Admin and measures against each element's content-box bottom, so a redundant sub-shell `pb-*` fails instead of inflating the allowed budget. @@ -156,6 +156,8 @@ A two-column sub-shell that needs a "don't jump while loading" floor derives it Settings + Notifications are **account utilities, not feature destinations**. They live ONLY in the user/avatar menu (mobile top-bar `DropdownMenu`; desktop sidebar avatar menu, with Settings additionally in the sidebar footer). The mobile "More" overflow hub carries **feature destinations only** (Measurements, Mood, Cycle, Labs, Coach, …), never Settings or Notifications. The hub is `mobileMoreHubDestinations()` = `visibleNavDestinations` minus the primary slots (`/`, `/medications`, `/insights`); it does **not** append the utility tail. `NAV_UTILITY_DESTINATIONS` (`visibleUtilityDestinations()`) feeds the sidebar footer + user menus, not the mobile overflow. Never surface a utility in two menus reachable from the same screen. +The rail's destination list is the one part of the rail that gives up height: the logo band, the footer group and the account row stay pinned, and the list scrolls between them (`overscroll-contain`, no native scrollbar, fades at an edge with more). A mouse gets the height-stepped row density; a finger keeps the 44 px rows, and at an edge with more destinations the rail shows a scroll cue (`sidebar-nav-scroll-cue`) that a tap follows. Guard: `e2e/tablet-shell.spec.ts`. + ## 11. Inline action row; the PageHeader pattern A title/sentence with an adjacent action is one row, not a wrapping stack: `flex items-start justify-between gap-3`, the text `min-w-0 flex-1` (wraps internally), the action `shrink-0` (stays beside the text, never pushed to its own line). This is the canonical `PageHeader` / `TileHeader` (`right` slot) shape; reuse it (e.g. the dashboard-hero verdict + CTA) instead of a one-off `flex-wrap` row whose `gap-y-*` drops the action below on a full sentence. Keep the mobile tap-target floor (`min-h-11 sm:min-h-9`). diff --git a/e2e/tablet-shell.spec.ts b/e2e/tablet-shell.spec.ts new file mode 100644 index 000000000..5dfd258a2 --- /dev/null +++ b/e2e/tablet-shell.spec.ts @@ -0,0 +1,420 @@ +import type { Locator, Page } from "@playwright/test"; + +import { mockDay } from "./setup/day-mock"; +import { expect, test } from "./setup/test"; +import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * The app shell on a tablet, under a finger, at every iPad size in both + * orientations: the rail reaches every destination, `
` scrolls to its + * last line, and closing a sheet or a dialog hands the scroll back. + * + * Runs in `webkit-tablet` (iPad, the engine an iPad runs; CI's WebKit job) + * and `chromium-tablet` (an Android tablet, in the Chromium shards). Both + * report `pointer: coarse`, so the rail keeps its 44 px rows and its list + * overflows in landscape, which is the case that broke. + * + * What a browser under test cannot show is the iPadOS toolbar moving the + * visible height away from `100dvh`; `shell-viewport.test.ts` holds the + * decisions that take that disagreement out, and this file measures that the + * shell they produce fills the window exactly and leaves the document + * nothing to scroll or bounce. + * + * Stable hooks only: `sidebar-nav-region`, `sidebar-nav-scroll-cue`, + * `sidebar-footer`, `main-content-wrapper`, `bottom-nav`, + * `bottom-nav-more` (test id), `command-palette-trigger`, + * `command-palette-input`, `day-panel`. + */ + +/** iPad and Android tablet sizes, CSS px, with the browser's own bars where they take room. */ +const VIEWPORTS = [ + { name: "iPad mini portrait", width: 744, height: 1133 }, + { name: "iPad mini landscape", width: 1133, height: 744 }, + { name: "iPad mini landscape, Safari bars", width: 1133, height: 670 }, + { name: "iPad landscape, Safari bars", width: 1000, height: 690 }, + { name: "iPad Air 11 portrait", width: 820, height: 1180 }, + { name: "iPad Air 11 landscape", width: 1180, height: 820 }, + { name: "iPad Pro 11 portrait", width: 834, height: 1194 }, + { name: "iPad Pro 11 landscape", width: 1194, height: 834 }, + { name: "iPad Pro 13 portrait", width: 1024, height: 1366 }, + { name: "iPad Pro 13 landscape", width: 1366, height: 1024 }, + { name: "Split View two thirds", width: 694, height: 1024 }, + { name: "Split View half, landscape", width: 590, height: 820 }, + { name: "Slide Over", width: 375, height: 1024 }, + { name: "Android tablet landscape", width: 1138, height: 712 }, +] as const; + +test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH }); + +async function openDashboard(page: Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect( + page.locator('#main-content [data-slot="main-content-wrapper"]'), + ).toBeVisible(); + // The module-gated destinations join the rail once `/api/auth/me` lands. + await expect(page.locator('aside nav a[href="/medications"]')).toHaveCount(1); +} + +/** The shell fills the window exactly and the document has nothing to scroll. */ +async function expectShellPinned(page: Page) { + const m = await page.evaluate(() => { + const shell = document.querySelector(".shell-safe-area")!; + const rect = shell.getBoundingClientRect(); + const root = document.scrollingElement!; + const html = getComputedStyle(document.documentElement); + return { + top: rect.top, + bottom: rect.bottom, + left: rect.left, + right: rect.right, + innerHeight, + innerWidth, + docScroll: root.scrollHeight - root.clientHeight, + overflowY: html.overflowY, + overscroll: html.overscrollBehaviorY, + }; + }); + expect(m.top).toBe(0); + expect(m.left).toBe(0); + expect(Math.abs(m.bottom - m.innerHeight)).toBeLessThanOrEqual(0.5); + expect(Math.abs(m.right - m.innerWidth)).toBeLessThanOrEqual(0.5); + expect(m.docScroll).toBeLessThanOrEqual(0); + expect(m.overflowY).toBe("hidden"); + // No rubber band on the document: on iOS it held the next swipe. + expect(m.overscroll).toBe("none"); +} + +function railNav(page: Page): Locator { + return page.locator('aside [data-slot="sidebar-nav-region"] > nav'); +} + +/** Scrolls `
` to its end, waiting out content that grows on the way. */ +async function scrollMainToEnd(page: Page) { + let last = -1; + for (let i = 0; i < 8; i += 1) { + const height = await page.evaluate(() => { + const main = document.getElementById("main-content")!; + main.scrollTop = main.scrollHeight; + return main.scrollHeight; + }); + if (height === last) return; + last = height; + await page.waitForTimeout(250); + } +} + +/** Where the page's lowest content ends, and what covers the window's bottom edge. */ +async function measureMainEnd(page: Page) { + return page.evaluate(() => { + const main = document.getElementById("main-content")!; + const wrapper = main.querySelector( + '[data-slot="main-content-wrapper"]', + )!; + let contentBottom = 0; + for (const el of wrapper.querySelectorAll("*")) { + const cs = getComputedStyle(el); + if (cs.position === "fixed" || cs.visibility === "hidden") continue; + const rect = el.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) continue; + let p = el.parentElement; + let nested = false; + while (p && p !== main) { + const ps = getComputedStyle(p); + if ( + /(auto|scroll|hidden)/.test(ps.overflowY) && + p.scrollHeight > p.clientHeight + 1 + ) { + nested = true; + break; + } + p = p.parentElement; + } + if (nested) continue; + contentBottom = Math.max(contentBottom, rect.bottom); + } + const visible = (el: Element | null): el is HTMLElement => + !!el && + getComputedStyle(el).display !== "none" && + el.getBoundingClientRect().height > 0; + const bar = document.querySelector('[data-slot="bottom-nav"]'); + const mainRect = main.getBoundingClientRect(); + return { + atEnd: main.scrollTop + main.clientHeight >= main.scrollHeight - 1, + scrolls: main.scrollHeight > main.clientHeight, + contentBottom, + mainBottom: mainRect.bottom, + barTop: visible(bar) ? bar.getBoundingClientRect().top : null, + }; + }); +} + +for (const vp of VIEWPORTS) { + test.describe(`${vp.name} (${vp.width}x${vp.height})`, () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: vp.width, height: vp.height }); + }); + + test("every rail destination scrolls into view and takes a tap", async ({ + page, + }) => { + await openDashboard(page); + await expectShellPinned(page); + + const aside = page.locator("aside"); + const railShown = vp.width >= 768; + if (!railShown) { + // Split View and Slide Over get the phone shell: the bar, no rail. + await expect(aside).toBeHidden(); + await expect(page.locator('[data-slot="bottom-nav"]')).toBeVisible(); + return; + } + await expect(aside).toBeVisible(); + + // Nothing in the rail is clipped by the rail: the logo band, the list + // region, the pinned footer and the account row all sit inside it, in + // that order, and the list region alone gives up height. + const parts = await page.evaluate(() => { + const asideEl = document.querySelector("aside")!; + const box = asideEl.getBoundingClientRect(); + const pick = (sel: string) => + asideEl.querySelector(sel)!.getBoundingClientRect(); + const column = asideEl.querySelector('[data-slot="sidebar-column"]')!; + const user = column.lastElementChild!.getBoundingClientRect(); + return { + clipped: asideEl.scrollHeight - asideEl.clientHeight, + asideTop: box.top, + asideBottom: box.bottom, + header: pick('[data-slot="sidebar-header"]'), + region: pick('[data-slot="sidebar-nav-region"]'), + footer: pick('[data-slot="sidebar-footer"]'), + user, + overscroll: getComputedStyle( + asideEl.querySelector('[data-slot="sidebar-nav-region"] > nav')!, + ).overscrollBehaviorY, + }; + }); + expect(parts.clipped).toBeLessThanOrEqual(0); + expect(parts.header.top).toBeGreaterThanOrEqual(parts.asideTop - 0.5); + expect(parts.region.top).toBeGreaterThanOrEqual( + parts.header.bottom - 0.5, + ); + expect(parts.footer.top).toBeGreaterThanOrEqual( + parts.region.bottom - 0.5, + ); + expect(parts.user.top).toBeGreaterThanOrEqual(parts.footer.bottom - 0.5); + expect(parts.user.bottom).toBeLessThanOrEqual(parts.asideBottom + 0.5); + expect(parts.overscroll).toBe("contain"); + await expect( + aside.locator('[data-slot="sidebar-collapse-toggle"]'), + ).toBeInViewport({ ratio: 1 }); + await expect( + aside.locator('[data-slot="nav-settings-link"]'), + ).toBeInViewport({ ratio: 1 }); + + // Every destination: scrolled into the list's view, whole, on top + // (not under the cue, the footer or anything else), and actionable. + const nav = railNav(page); + const links = nav.locator("a[href]"); + const count = await links.count(); + expect(count).toBeGreaterThanOrEqual(10); + for (let i = 0; i < count; i += 1) { + const link = links.nth(i); + await link.scrollIntoViewIfNeeded(); + await expect + .poll(() => + link.evaluate((el) => { + const navEl = el.closest("nav")!; + const r = el.getBoundingClientRect(); + const n = navEl.getBoundingClientRect(); + const hit = document.elementFromPoint( + r.left + r.width / 2, + r.top + r.height / 2, + ); + return ( + r.top >= n.top - 0.5 && + r.bottom <= n.bottom + 0.5 && + !!hit && + el.contains(hit) + ); + }), + ) + .toBe(true); + await link.click({ trial: true }); + } + + // A list taller than its region shows the touch cue at the edge that + // has more, and a tap on it is a way there. + const overflows = await nav.evaluate( + (el) => el.scrollHeight > el.clientHeight + 1, + ); + const down = page.locator( + '[data-slot="sidebar-nav-scroll-cue"][data-direction="down"]', + ); + const up = page.locator( + '[data-slot="sidebar-nav-scroll-cue"][data-direction="up"]', + ); + await nav.evaluate((el) => (el.scrollTop = 0)); + if (overflows) { + await expect(down).toBeVisible(); + await expect(up).toHaveCount(0); + await down.tap(); + await expect + .poll(() => nav.evaluate((el) => el.scrollTop)) + .toBeGreaterThan(0); + await expect(up).toBeVisible(); + await nav.evaluate((el) => (el.scrollTop = el.scrollHeight)); + await expect(down).toHaveCount(0); + } else { + await expect(down).toHaveCount(0); + await expect(up).toHaveCount(0); + } + + // And the last destination is a real tap away. + const lastLink = links.nth(count - 1); + const href = await lastLink.getAttribute("href"); + await lastLink.scrollIntoViewIfNeeded(); + await lastLink.tap(); + await expect(page).toHaveURL(new RegExp(`${href}$`)); + }); + + // The Coach button's clearance is held by `shell-viewport.test.ts`: the + // button renders only with an AI provider, which the suite's accounts + // do not have, so a check here would never run. + test("main scrolls to its last line, clear of the bottom bar", async ({ + page, + }) => { + await openDashboard(page); + await scrollMainToEnd(page); + const end = await measureMainEnd(page); + expect(end.scrolls).toBe(true); + expect(end.atEnd).toBe(true); + expect(end.contentBottom).toBeLessThanOrEqual(end.mainBottom + 0.5); + if (end.barTop !== null) { + expect(end.contentBottom).toBeLessThanOrEqual(end.barTop); + } + await expectShellPinned(page); + }); + }); +} + +/** After a modal closes, the page behind takes pointer and wheel again and scrolls. */ +async function expectPageScrollsAgain(page: Page, browserName: string) { + await expect + .poll(() => + page.evaluate(() => ({ + locked: document.body.hasAttribute("data-scroll-locked"), + pointer: getComputedStyle(document.body).pointerEvents, + })), + ) + .toEqual({ locked: false, pointer: "auto" }); + const before = await page.evaluate(() => { + const main = document.getElementById("main-content")!; + main.scrollTop = 0; + const r = main.getBoundingClientRect(); + const x = r.left + r.width / 2; + const y = r.top + r.height / 2; + const hit = document.elementFromPoint(x, y); + const wheel = new WheelEvent("wheel", { + deltaY: 120, + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + }); + hit?.dispatchEvent(wheel); + return { + onMain: !!hit && main.contains(hit), + blocked: wheel.defaultPrevented, + x, + y, + }; + }); + // Nothing invisible lies over `
`, and no scroll lock swallows the + // wheel (react-remove-scroll cancels it outside its own layer). + expect(before.onMain).toBe(true); + expect(before.blocked).toBe(false); + // A real wheel where the engine has one. Mobile WebKit under Playwright + // has none (and no native touch scroll), so there the two checks above + // are the proof: nothing covers `
` and nothing cancels its events. + if (browserName === "webkit") return; + await page.mouse.move(before.x, before.y); + await page.mouse.wheel(0, 600); + await expect + .poll(() => + page.evaluate(() => document.getElementById("main-content")!.scrollTop), + ) + .toBeGreaterThan(0); +} + +const MODAL_VIEWPORTS = [ + { name: "iPad Air 11 landscape", width: 1180, height: 820 }, + { name: "iPad Air 11 portrait", width: 820, height: 1180 }, + { name: "Split View two thirds", width: 694, height: 1024 }, +] as const; + +for (const vp of MODAL_VIEWPORTS) { + test.describe(`${vp.name}: scroll after a modal`, () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: vp.width, height: vp.height }); + }); + + test("the page scrolls after the command palette dialog closes", async ({ + page, + browserName, + }) => { + await openDashboard(page); + await page.locator('[data-slot="command-palette-trigger"]:visible').tap(); + const input = page.locator('[data-slot="command-palette-input"]'); + await expect(input).toBeVisible(); + // The scroll lock makes the body `position: relative`; the shell + // behind the dialog keeps the window's box all the same. + await expectShellPinned(page); + await page.keyboard.press("Escape"); + await expect(input).toBeHidden(); + await expectPageScrollsAgain(page, browserName); + }); + + test("the page scrolls after a sheet closes", async ({ + page, + browserName, + }) => { + await openDashboard(page); + if (vp.width < 768) { + // The phone shell's More sheet: the draggable bottom sheet. + await page.locator('[data-testid="bottom-nav-more"]').tap(); + const sheet = page.locator('[data-testid="bottom-nav-more-sheet"]'); + await expect(sheet).toBeVisible(); + await expectShellPinned(page); + await sheet.locator('[data-slot="sheet-close-button"]').tap(); + await expect(sheet).toBeHidden(); + } else { + // The day, a sheet from the right below 1280 px. + await mockDay(page); + const day = new Date(Date.now() - 86_400_000) + .toISOString() + .slice(0, 10); + await page.goto(`/?day=${day}`, { waitUntil: "domcontentloaded" }); + const panel = page.locator('[data-slot="day-panel"]'); + await expect(panel).toBeVisible(); + await expectShellPinned(page); + await page.keyboard.press("Escape"); + await expect(panel).toBeHidden(); + } + await expectPageScrollsAgain(page, browserName); + }); + }); +} + +test("a vertical swipe that starts on a tab strip is the page's", async ({ + page, +}) => { + await page.setViewportSize({ width: 1180, height: 820 }); + await page.goto("/checkups", { waitUntil: "domcontentloaded" }); + const strip = page.locator('#main-content [data-slot="tabs-list"]').first(); + await expect(strip).toBeVisible(); + // `pan-x` here refused the vertical pan, so the page did not move. + const touchAction = await strip.evaluate( + (el) => getComputedStyle(el).touchAction, + ); + expect(touchAction).toBe("auto"); +}); diff --git a/playwright.config.ts b/playwright.config.ts index cd8a69347..4fbee7e5f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -123,6 +123,8 @@ export default defineConfig({ "pwa-offline.spec.ts", // Runs only in the WebKit phone project. "bottom-sheet.spec.ts", + // Runs only in the two tablet projects. + "tablet-shell.spec.ts", ], use: { ...devices["Desktop Chrome"], @@ -240,6 +242,8 @@ export default defineConfig({ "pwa-offline.spec.ts", // Runs only in the WebKit phone project. "bottom-sheet.spec.ts", + // Runs only in the two tablet projects. + "tablet-shell.spec.ts", // The Apple Health import journey uploads an archive into the one // shared account and its refusal control asserts that the account's // Apple-Health row count did not move. A second project running the @@ -333,7 +337,28 @@ export default defineConfig({ colorScheme: "dark" as const, }, }, + // The app shell on an iPad (`tablet-shell.spec.ts`): the rail + // under a finger, `
` to its last line, scroll after a modal. + // In CI's WebKit job beside `webkit-iphone`. The spec sets each + // iPad size itself; the profile supplies touch and `isMobile`. + { + name: "webkit-tablet", + testMatch: ["tablet-shell.spec.ts"], + use: { + ...devices["iPad Pro 11 landscape"], + colorScheme: "dark" as const, + }, + }, ]), + // The same spec on an Android tablet, in the Chromium shards. + { + name: "chromium-tablet", + testMatch: ["tablet-shell.spec.ts"], + use: { + ...devices["Galaxy Tab S4 landscape"], + colorScheme: "dark" as const, + }, + }, ], // Spin up the production build for E2E. `pnpm build` is run From 0e7a18667772ebb6e4d5a8e3e7fef2e74f5b9673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 18:24:30 +0200 Subject: [PATCH 5/5] test(ratchet): lower the shell and rail baselines sidebar-nav.tsx drops under 800 lines with its scroll region in a module of its own, and AuthShell and SidebarNav are shorter. --- src/__tests__/baselines/source-file-lines.json | 5 ++--- src/__tests__/baselines/source-function-lines.json | 6 +++--- src/__tests__/baselines/version-stamp-comments.json | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 434124a03..fef8023c4 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 91, - "total": 101342, + "count": 90, + "total": 100487, "entries": { "app/medications/page-client.tsx": 839, "components/charts/mood-chart.tsx": 1166, @@ -12,7 +12,6 @@ "components/insights/derived/vitals-dashboard.tsx": 1033, "components/insights/health-score-card.tsx": 1023, "components/insights/insights-tab-strip.tsx": 1086, - "components/layout/sidebar-nav.tsx": 855, "components/measurements/measurement-form.tsx": 815, "components/medications/detail/medication-detail-tabs.tsx": 1160, "components/medications/medication-table.tsx": 853, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 4dcbcf91c..e6ccf5f2a 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 355, - "total": 114773, + "total": 114755, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -143,10 +143,10 @@ "components/labs/lab-list.tsx :: LabList": 308, "components/labs/ocr-review-dialog.tsx :: OcrReviewDialog": 359, "components/labs/ocr-row-editor.tsx :: OcrRowEditor": 393, - "components/layout/auth-shell.tsx :: AuthShell": 520, + "components/layout/auth-shell.tsx :: AuthShell": 518, "components/layout/bottom-nav.tsx :: BottomNav": 262, "components/layout/capture-picker.tsx :: CapturePicker": 235, - "components/layout/sidebar-nav.tsx :: SidebarNav": 372, + "components/layout/sidebar-nav.tsx :: SidebarNav": 356, "components/layout/top-bar.tsx :: TopBar": 212, "components/links/entity-link-picker.tsx :: EntityLinkPicker": 392, "components/measurement-reminders/vorsorge-dashboard-card.tsx :: VorsorgeDashboardCard": 215, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 32b18fa49..3bc029651 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2898, - "total": 9297, + "total": 9296, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1098,7 +1098,7 @@ "components/layout/__tests__/shared-record-chrome.test.tsx": 1, "components/layout/__tests__/sidebar-nav.test.tsx": 3, "components/layout/account-switcher-menu.tsx": 1, - "components/layout/auth-shell.tsx": 15, + "components/layout/auth-shell.tsx": 14, "components/layout/bottom-nav.tsx": 8, "components/layout/capture-picker.tsx": 5, "components/layout/nav-model.ts": 17,