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/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/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/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/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 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 8c6ac48ff..daf9621c7 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": 114676, + "total": 114658, "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 0e84d0c5f..2717c6546 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": 9296, + "total": 9295, "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, 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/__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/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 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 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: {