Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-server

# Conflicts:
#	src/__tests__/baselines/source-function-lines.json
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 19:28:58 +02:00
commit f55a86a08c
20 files changed
+1004 -140

No files matched your search

+5 -4
View File
@@ -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
+5 -3
View File
@@ -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 + `<md` bottom-nav clearance on `<main>`). Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `<main>`; 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 `<main>`). The shell itself takes the window's edges (`absolute inset-0`), never `h-dvh`, and `<main>` 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 `<main>`; 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 `<main>` 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 `<main>`; 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 `<main>` 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 `<main>`; 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`).
+2 -2
View File
@@ -8,7 +8,7 @@ import { settleBeforeMeasure } from "./utils/settle";
* v1.25.11 settings fix): the scroll viewport must never extend far past
* the last content element. The rule the guard pins is structural — only
* the AuthShell owns the scroll height and the bottom padding
* (`pt-6 pb-20` on the wrapper, plus the `<md` bottom-nav clearance on
* (`pt-6 pb-20` on the wrapper, `pb-24` in the desktop shell, plus the `<md` bottom-nav clearance on
* `<main>`); a page/section must never add its own viewport-height
* reserve or bottom gutter, because every such nested reserve stacks on
* the shell's own budget and reopens the dark-band-below-the-last-card
@@ -45,7 +45,7 @@ const ROUTES = [
] as const;
const VIEWPORTS = [
{ name: "desktop", width: 1440, height: 900, expectedPad: 80 }, // wrapper pb-20
{ name: "desktop", width: 1440, height: 900, expectedPad: 96 }, // wrapper shell-desktop:pb-24
{ name: "phone", width: 390, height: 844, expectedPad: 144 }, // pb-20 + main pb-16
] as const;
+420
View File
@@ -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, `<main>` 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<HTMLElement>(".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 `<main>` 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<HTMLElement>(
'[data-slot="main-content-wrapper"]',
)!;
let contentBottom = 0;
for (const el of wrapper.querySelectorAll<HTMLElement>("*")) {
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 `<main>`, 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 `<main>` 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");
});
+3 -3
View File
@@ -845,9 +845,9 @@ test("the timeline page ends where its content does, at every desktop width", as
expect(m.mainSw, where).toBeLessThanOrEqual(m.mainCw + 1);
expect(m.docSw, where).toBeLessThanOrEqual(m.docCw + 1);
expect(m.docSh, where).toBeLessThanOrEqual(m.docCh + 1);
// The shell's own buffer below the last card (pb-20 and the top
// padding), nothing more.
expect(m.mainSh, where).toBeLessThanOrEqual(m.contentBottom + 104 + 2);
// The shell's own buffer below the last card (the desktop shell's
// pb-24 and the top padding), nothing more.
expect(m.mainSh, where).toBeLessThanOrEqual(m.contentBottom + 120 + 2);
}
}
});
+25
View File
@@ -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, `<main>` 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
@@ -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,
@@ -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,
@@ -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,
+4 -1
View File
@@ -33,7 +33,10 @@ function code(source: string): string {
describe("the shell owns the safe area", () => {
it("the authenticated shell wrapper takes the insets", () => {
const shell = code(read("src/components/layout/auth-shell.tsx"));
expect(shell).toMatch(/className="shell-safe-area flex h-dvh flex-col"/);
expect(shell).toMatch(/<div className=\{SHELL_ROOT_CLASS\}>/);
expect(read("src/components/layout/shell-metrics.ts")).toMatch(
/SHELL_ROOT_CLASS =\s*"shell-safe-area absolute inset-0 flex flex-col"/,
);
});
it.each([
+22
View File
@@ -615,6 +615,28 @@ body,
html[data-scroll="body"] {
scrollbar-gutter: stable;
}
/*
* The app shell (`data-scroll="shell"`, also set by `AuthShell`) takes the
* window's edges and scrolls inside `<main>`, so the document has nothing
* to scroll and must not bounce either. On iOS a swipe that reached the end
* of `<main>` or of the rail chained to the document, the whole page
* rubber-banded and showed the page colour under the shell, and the next
* swipe went to that bounce instead of the list under the finger: the
* "sometimes it does not scroll" a tablet reported.
*
* `height: 100%` gives the body the window's box. The shell is
* `absolute inset-0`, which resolves against the initial containing block
* while the body is static, and against the body once a dialog's scroll
* lock makes it `position: relative` (react-remove-scroll does); an
* auto-height body is 0 px tall and the shell would collapse behind every
* dialog.
*/
html[data-scroll="shell"],
html[data-scroll="shell"] body {
height: 100%;
overflow: hidden;
overscroll-behavior: none;
}
body {
@apply bg-background text-foreground;
}
@@ -0,0 +1,119 @@
/**
* The authenticated shell's geometry decisions, which no browser under test
* can show going wrong: Playwright's WebKit and Chromium have no floating
* toolbar, no rotation and no home indicator, so `100dvh` always equals the
* window there. On an iPad it does not, and that disagreement was the strip
* under the shell and the cut-off card. These hold the decisions that take
* the disagreement out; `e2e/tablet-shell.spec.ts` measures the result.
*/
import { describe, expect, it } from "vitest";
import { readSource } from "@/__tests__/helpers/source-files";
import {
MAIN_SCROLL_CLASS,
SHELL_ROOT_CLASS,
SUB_SHELL_GRID_FLOOR,
} from "../shell-metrics";
const shellSource = () =>
readSource("src/components/layout/auth-shell.tsx", [
"SHELL_ROOT_CLASS",
'data-slot="main-content-wrapper"',
]);
/** The page frame's class list: the one `mx-auto max-w-screen-xl` literal. */
const MAIN_CONTENT_WRAPPER_CLASS =
/"([^"]*\bmx-auto max-w-screen-xl\b[^"]*)"/.exec(shellSource())![1];
const classes = (value: string) => value.split(/\s+/).filter(Boolean);
/** Tailwind spacing step or `[calc(env(...)+Nrem)]` offset, in rem. */
function rem(token: string): number {
const calc = /\+(\d+(?:\.\d+)?)rem\)\]$/.exec(token);
if (calc) return Number(calc[1]);
const step = /-(\d+(?:\.\d+)?)$/.exec(token);
if (step) return Number(step[1]) / 4;
throw new Error(`no length in ${token}`);
}
describe("the shell takes the window's edges", () => {
it("takes its height from the initial containing block, never from a viewport unit", () => {
const shell = classes(SHELL_ROOT_CLASS);
expect(shell).toEqual(
expect.arrayContaining(["shell-safe-area", "absolute", "inset-0"]),
);
// `fixed` re-rasterises the whole app in its own layer and traps the
// bottom bar's z-index in a stacking context.
expect(shell).not.toContain("fixed");
expect(SHELL_ROOT_CLASS).not.toMatch(
/\b(?:min-)?h-(?:dvh|svh|lvh|screen)\b/,
);
expect(SHELL_ROOT_CLASS).not.toMatch(/\d+(?:d|s|l)?vh/);
});
it("AuthShell renders that box and flags the document for it", () => {
const source = shellSource();
expect(source).toContain("<div className={SHELL_ROOT_CLASS}>");
expect(source).not.toMatch(/shell-safe-area flex h-dvh/);
expect(source).toMatch(
/useDocumentScrollMode\(\s*isBodyScrolled \? "body" : rendersAppShell \? "shell" : null/,
);
const css = readSource("src/app/globals.css", '[data-scroll="shell"]');
expect(css).toMatch(
/html\[data-scroll="shell"\],\s*html\[data-scroll="shell"\] body\s*\{\s*height:\s*100%;\s*overflow:\s*hidden;\s*overscroll-behavior:\s*none;/,
);
});
});
describe("the bottom edge is cleared in both shells", () => {
const fab = readSource(
"src/components/insights/layout-coach-fab.tsx",
'data-slot="coach-fab"',
);
const fabSize = rem(/\bsize-(\d+)\b/.exec(fab)![0]);
const phoneFabBottom = rem(
/(?<![\w:-])bottom-\[calc\(env\(safe-area-inset-bottom,0px\)\+[\d.]+rem\)\]/.exec(
fab,
)![0],
);
const desktopFabBottom = rem(
/shell-desktop:bottom-\[calc\(env\(safe-area-inset-bottom,0px\)\+[\d.]+rem\)\]/.exec(
fab,
)![0],
);
const wrapper = classes(MAIN_CONTENT_WRAPPER_CLASS);
const main = classes(MAIN_SCROLL_CLASS);
it("the desktop shell's main keeps the home indicator off the last line", () => {
expect(main).not.toContain("shell-desktop:pb-0");
expect(main).toContain(
"shell-desktop:pb-[env(safe-area-inset-bottom,0px)]",
);
});
it("the last line scrolls clear of the Coach button in the phone shell", () => {
const mainPad = Number(
/^pb-\[calc\((\d+(?:\.\d+)?)rem\+env\(safe-area-inset-bottom,0px\)\)\]$/.exec(
main.find((c) => c.startsWith("pb-[calc("))!,
)![1],
);
const wrapperPad = rem(wrapper.find((c) => /^pb-\d+$/.test(c))!);
expect(mainPad + wrapperPad).toBeGreaterThan(phoneFabBottom + fabSize);
});
it("the last line scrolls clear of the Coach button in the desktop shell", () => {
const wrapperPad = rem(
wrapper.find((c) => /^shell-desktop:pb-\d+$/.test(c))!,
);
expect(wrapperPad).toBeGreaterThan(desktopFabBottom + fabSize);
});
it("the sub-shell floor follows the desktop shell's padding", () => {
// Top bar 4rem + pt-6 + the wrapper's bottom padding.
const desktopPad = rem(
wrapper.find((c) => /^shell-desktop:pb-\d+$/.test(c))!,
);
expect(SUB_SHELL_GRID_FLOOR).toContain(
`shell-desktop:min-h-[calc(100dvh-${4 + 1.5 + desktopPad}rem)]`,
);
});
});
@@ -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("<SidebarNav> carries no rules for a viewport it never shows on", () =>
expect(source).not.toMatch(/pointer:coarse\)_and_\(height<=520px\)/);
});
});
describe("<SidebarNav> keeps every destination reachable on a touch tablet", () => {
function navClass(html: string): string {
return (
/<nav[^>]*aria-label="[^"]*"[^>]*class="([^"]*)"/.exec(html)?.[1] ?? ""
);
}
it("scrolls the list inside the rail and keeps the swipe there", () => {
const nav = navClass(render()).replaceAll("&amp;", "&").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 = /<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(
<RailScrollCue direction={direction} navRef={{ current: null }} />,
);
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" }]);
});
});
+23 -23
View File
@@ -38,6 +38,8 @@ import {
} from "@/components/layout/module-page-gate";
import { cn } from "@/lib/utils";
import { isFullBleedPage } from "./full-bleed";
import { useDocumentScrollMode } from "./document-scroll-mode";
import { MAIN_SCROLL_CLASS, SHELL_ROOT_CLASS } from "./shell-metrics";
import { isOnboardingPathname } from "@/lib/onboarding/wizard-steps";
import { RecordScopeHydrationGate } from "./record-scope-hydration-gate";
import { SidebarNav } from "./sidebar-nav";
@@ -166,25 +168,18 @@ export function AuthShell({
const isOnboardingPage = isOnboardingPathname(pathname);
const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id;
// v1.9.0 — the document-level scrollbar-gutter (globals.css) is reserved
// only for the body-scrolled shells (login / register / onboarding /
// standalone legal pages). The authenticated branch is height-locked and
// scrolls inside its own `<main>`, which reserves its own gutter; applying
// the document gutter there too produced a second, never-painted gutter on
// classic-scrollbar platforms. Flag the body-scrolled branches on `<html>`
// so the CSS rule scopes to them.
// Who scrolls the page, on `<html>` (`document-scroll-mode.ts`).
const isBodyScrolled = isPublicPage || isOnboardingPage;
useEffect(() => {
const root = document.documentElement;
if (isBodyScrolled) {
root.setAttribute("data-scroll", "body");
} else {
root.removeAttribute("data-scroll");
}
return () => {
root.removeAttribute("data-scroll");
};
}, [isBodyScrolled]);
const rendersAppShell =
!isBodyScrolled &&
!accessRefused &&
!isLoading &&
isAuthenticated &&
recordSessionTransition.phase === "ready" &&
!recordSessionPending;
useDocumentScrollMode(
isBodyScrolled ? "body" : rendersAppShell ? "shell" : null,
);
// v1.25.1 — the authenticated shell keeps `<main id="main-content">`
// mounted across every route change, so its scroll offset survives
@@ -438,9 +433,13 @@ export function AuthShell({
five pixels of content box and pushed the logo and the account menu
across its bottom border. `shell-safe-area` paints the strip behind
the status bar in the chrome colour (globals.css). The bottom inset
stays with the bottom nav and `<main>`, which own that edge.
stays with the bottom nav, `<main>` and the rail, which own that edge.
The shell takes the window's edges rather than `h-dvh`, and the
document under it neither scrolls nor bounces: `SHELL_ROOT_CLASS` in
`shell-metrics.ts` says why.
*/}
<div className="shell-safe-area flex h-dvh flex-col">
<div className={SHELL_ROOT_CLASS}>
{/*
v1.4.43 — `<OfflineBanner>` paints only when
`navigator.onLine === false`. Sits above the maintainership
@@ -500,7 +499,7 @@ export function AuthShell({
// for all of them at once instead of asking each new
// absolutely-positioned child to remember its own wrapper.
className={cn(
"shell-desktop:pb-0 relative flex-1 overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))]",
MAIN_SCROLL_CLASS,
fullBleed ? "flex flex-col" : "[scrollbar-gutter:stable]",
)}
>
@@ -516,7 +515,8 @@ export function AuthShell({
v1.16.8 — the bottom padding clears the always-on Coach
FAB (48 px button + its offset above the bottom-nav band /
the desktop bottom-6 anchor), so the last line of content
can always scroll out from under the floating button.
can always scroll out from under the floating button; the
desktop shell's sits 5.5rem up, so `pb-24` there.
*/}
<div
data-slot="main-content-wrapper"
@@ -524,7 +524,7 @@ export function AuthShell({
className={
fullBleed
? "flex min-h-0 flex-1 flex-col"
: "mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
: "shell-desktop:pb-24 mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
}
>
{outsideSharedRecord ? (
@@ -0,0 +1,36 @@
"use client";
import { useEffect } from "react";
/**
* Who scrolls the page, published on `<html>` as `data-scroll` for the rules
* in `globals.css`.
*
* - `"body"`: the body-scrolled shells (login / register / onboarding /
* standalone legal pages), where the document itself overflows. They get
* the document-level `scrollbar-gutter`. The authenticated branch
* scrolls inside its own `<main>`, which reserves its own gutter; the
* document gutter there too produced a second, never-painted gutter on
* classic-scrollbar platforms.
* - `"shell"`: the app shell. It takes the window's edges and `<main>` is its
* one vertical scroller, so the document gets no scroll and no rubber band
* of its own. On iOS a bounce of the document held the next swipe, and the
* list under the finger did not move.
* - `null`: anything else (the refusal panel, the loading states) keeps the
* browser's defaults.
*/
export type DocumentScrollMode = "body" | "shell" | null;
export function useDocumentScrollMode(mode: DocumentScrollMode): void {
useEffect(() => {
const root = document.documentElement;
if (mode) {
root.setAttribute("data-scroll", mode);
} else {
root.removeAttribute("data-scroll");
}
return () => {
root.removeAttribute("data-scroll");
};
}, [mode]);
}
+40 -4
View File
@@ -18,15 +18,51 @@
* never drift — Admin previously carried a stale, all-breakpoint
* `min-h-[calc(100dvh-12rem)]` column reserve that this de-duplication retires.
*
* Budget: top bar 4rem + the AuthShell wrapper's `pt-6` (1.5rem) + `pb-20`
* (5rem) = 10.5rem. Retune here — and only here — if those shell paddings
* change. (Latent follow-up: this constant assumes zero banners above `<main>`;
* Budget: top bar 4rem + the AuthShell wrapper's `pt-6` (1.5rem) + its bottom
* padding: `pb-20` (5rem) = 10.5rem in the phone shell, `pb-24` (6rem) =
* 11.5rem in the desktop shell (the page frame in `auth-shell.tsx`). Retune here —
* and only here — if those shell paddings change. (Latent follow-up: this constant assumes zero banners above `<main>`;
* a locale/offline/demo banner or an iOS PWA safe-area inset makes it
* over-reserve. A fully self-accounting `min-h-full` chain would need the
* AuthShell content wrapper to pass a definite height through, which is out of
* scope for the shell-only over-scroll fix.)
*/
export const SUB_SHELL_GRID_FLOOR = "md:min-h-[calc(100dvh-10.5rem)]";
export const SUB_SHELL_GRID_FLOOR =
"md:min-h-[calc(100dvh-10.5rem)] shell-desktop:min-h-[calc(100dvh-11.5rem)]";
/**
* The authenticated shell's outer box. It takes the window's edges
* (`absolute inset-0` against the initial containing block) rather than
* `h-dvh`, and the top and side safe-area insets (`shell-safe-area`,
* globals.css); the bottom inset belongs to whatever owns the bottom edge in
* each shell.
*
* Not a viewport unit: `h-dvh` is the browser's idea of the visible height,
* and on iPadOS Safari that idea and the area it lays out disagree after a
* rotation and under the floating toolbars. A short value left a strip of
* page colour under the shell with the last card cut off above it; a tall one
* made the document scroll by the difference, and that document scroll took
* the swipes meant for `<main>`. The initial containing block is the layout
* itself, recomputed with every rotation and resize, and the document never
* scrolls (`data-scroll="shell"`, `document-scroll-mode.ts`), so the box
* cannot drift from the window either.
*
* Not `fixed`: a fixed box gets a compositing layer of its own, which
* re-rasterised the text of every page (the antialiasing moved on every
* screen), and forms a stacking context that would trap the bottom bar's
* z-index under the Coach button and the toasts.
*/
export const SHELL_ROOT_CLASS =
"shell-safe-area absolute inset-0 flex flex-col";
/**
* `<main>`, the shell's one vertical scroller. Phone shell: it clears the
* bottom nav and the home indicator. Desktop shell: there is no bar, and it
* clears the home indicator of an installed app on a tablet (zero in a
* browser tab), so the last line never sits under it.
*/
export const MAIN_SCROLL_CLASS =
"relative flex-1 overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] shell-desktop:pb-[env(safe-area-inset-bottom,0px)]";
/**
* The app-chrome header band: one 4rem strip whose bottom border draws the
@@ -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<HTMLElement | null>;
}) {
const Icon = direction === "down" ? ChevronDown : ChevronUp;
return (
<button
type="button"
tabIndex={-1}
aria-hidden="true"
data-slot="sidebar-nav-scroll-cue"
data-direction={direction}
onClick={() => {
const nav = navRef.current;
if (!nav) return;
const reduce = window.matchMedia?.(
"(prefers-reduced-motion: reduce)",
).matches;
nav.scrollBy({
top: (direction === "down" ? 1 : -1) * nav.clientHeight * 0.75,
behavior: reduce ? "auto" : "smooth",
});
}}
className={cn(
"absolute inset-x-0 z-10 hidden h-11 justify-center outline-none pointer-coarse:flex",
direction === "down"
? "bottom-0 items-end pb-1"
: "top-0 items-start pt-1",
)}
>
<span className="bg-sidebar border-sidebar-border text-foreground flex size-7 items-center justify-center rounded-full border shadow-sm">
<Icon className="size-4" />
</span>
</button>
);
}
/** 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<HTMLElement | null>): {
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 `<nav>` that scrolls, with its edge fades, and the
* touch cues over its edges.
*
* The list scrolls without a native scrollbar (a classic scrollbar with
* arrow buttons read as a foreign body in the rail); fades at the edges say
* there is more, and focus keeps the focused row clear of them.
* `overscroll-contain` (under a finger) keeps a swipe that reaches either end
* inside the rail: on iOS it chained to the document, which rubber-banded and
* held the next swipe, so the list seemed not to scroll.
*/
export function RailNavList({
navRef,
edges,
label,
className,
children,
}: {
navRef: React.RefObject<HTMLElement | null>;
edges: { moreAbove: boolean; moreBelow: boolean };
label: string;
className?: string;
children: React.ReactNode;
}) {
return (
<div
data-slot="sidebar-nav-region"
className="relative flex min-h-0 flex-1 flex-col"
>
<nav
ref={navRef}
aria-label={label}
className={cn(RAIL_NAV_SCROLL, className)}
style={railFadeMask(edges.moreAbove, edges.moreBelow)}
data-fade-top={edges.moreAbove ? "true" : undefined}
data-fade-bottom={edges.moreBelow ? "true" : undefined}
onFocus={(event) => {
event.target.scrollIntoView?.({ block: "nearest" });
}}
>
{children}
</nav>
{edges.moreAbove && <RailScrollCue direction="up" navRef={navRef} />}
{edges.moreBelow && <RailScrollCue direction="down" navRef={navRef} />}
</div>
);
}
+14 -89
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import {
Bell,
Keyboard,
@@ -27,6 +27,10 @@ import {
visibleUtilityDestinations,
} from "@/components/layout/nav-model";
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
import {
RailNavList,
useNavOverflows,
} from "@/components/layout/sidebar-nav-scroll";
import { cn } from "@/lib/utils";
import { Logo } from "@/components/ui/logo";
import { useAuth, useLogout } from "@/hooks/use-auth";
@@ -367,69 +371,6 @@ function railRowClass(collapsed: boolean): string {
);
}
/** 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.
*/
function useNavOverflows(navRef: React.RefObject<HTMLElement | null>): {
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 sidebar's collapse / expand control, drawn as a footer nav row.
*
@@ -547,11 +488,8 @@ export function SidebarNav() {
const collapsed = mounted ? (collapsedPref ?? tabletOrBelow) : false;
const navRef = useRef<HTMLElement | null>(null);
const {
overflows: navOverflows,
moreAbove: navMoreAbove,
moreBelow: navMoreBelow,
} = useNavOverflows(navRef);
const navEdges = useNavOverflows(navRef);
const navOverflows = navEdges.overflows;
function toggleCollapsed() {
const next = !collapsed;
@@ -689,7 +627,7 @@ export function SidebarNav() {
<aside
aria-label={t("nav.sidebar")}
className={cn(
"bg-sidebar border-sidebar-border shell-desktop:flex shell-desktop:flex-col relative hidden h-full flex-shrink-0 overflow-hidden border-r transition-[width] duration-200 motion-reduce:transition-none",
"bg-sidebar border-sidebar-border shell-desktop:flex shell-desktop:flex-col relative hidden h-full flex-shrink-0 overflow-hidden border-r pb-[env(safe-area-inset-bottom,0px)] transition-[width] duration-200 motion-reduce:transition-none",
collapsed ? "w-16" : "w-64",
)}
>
@@ -737,24 +675,11 @@ export function SidebarNav() {
</Link>
</div>
<nav
ref={navRef}
aria-label={t("nav.mainNavigation")}
// The list scrolls without a native scrollbar (a classic
// scrollbar with arrow buttons read as a foreign body in the
// rail); fades at the edges say there is more, and focus keeps
// the focused row clear of them.
className={cn(
"flex-1 scroll-py-6 [scrollbar-width:none] overflow-y-auto [&::-webkit-scrollbar]:hidden",
collapsed ? "p-1.5" : "p-3",
RAIL_SHORT_PY,
)}
style={railFadeMask(navMoreAbove, navMoreBelow)}
data-fade-top={navMoreAbove ? "true" : undefined}
data-fade-bottom={navMoreBelow ? "true" : undefined}
onFocus={(event) => {
event.target.scrollIntoView?.({ block: "nearest" });
}}
<RailNavList
navRef={navRef}
edges={navEdges}
label={t("nav.mainNavigation")}
className={cn(collapsed ? "p-1.5" : "p-3", RAIL_SHORT_PY)}
>
<div className={RAIL_LIST_GAP}>
{visibleNavItems.map((item) => {
@@ -811,7 +736,7 @@ export function SidebarNav() {
);
})}
</div>
</nav>
</RailNavList>
{/* Bottom utility links — the shared utility tail (minus
Notifications, which lives in the avatar menu) with the
+17
View File
@@ -30,4 +30,21 @@ describe("<TabsList>", () => {
// 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(
<Tabs defaultValue="a">
<TabsList>
<TabsTrigger value="a">A</TabsTrigger>
</TabsList>
</Tabs>,
);
const list = html.match(/<div\b[^>]*data-slot="tabs-list"[^>]*>/)![0];
expect(list).not.toMatch(
/\btouch-(?:pan-x|pan-left|pan-right|none|pinch-zoom)\b/,
);
});
});
+8 -3
View File
@@ -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: {