Files
HealthLog/e2e/settings-overscroll.spec.ts
Marc-André Bombeck 6ad61cdd1f fix(shell): take the window's edges and keep the document still
The authenticated shell was sized by h-dvh. On iPadOS Safari that value
and the area the browser lays out disagree after a rotation and under the
floating toolbars: a short value left a strip of page colour under the
shell with the last card cut off above it, a tall one let the document
scroll by the difference and take the swipes meant for main.

The shell now takes the window's edges (absolute inset-0 against the
initial containing block), and the document under it gets no scroll and
no rubber band (data-scroll="shell": overflow hidden, overscroll none,
height 100% so the box survives a dialog's scroll lock making the body
relative). Not fixed: a fixed box re-rasterises every page in a layer of
its own and traps the bottom bar's z-index.

The desktop shell's main now clears the home indicator of an installed
app, and the page frame clears the Coach button there with pb-24: the
button sits 5.5rem up, so pb-20 left the last 8 px under it. The
sub-shell grid floor follows the new padding.
2026-10-23 18:23:48 +02:00

199 lines
9.4 KiB
TypeScript

import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { settleBeforeMeasure } from "./utils/settle";
/**
* Vertical over-scroll regression guard (the recurring class behind the
* 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, `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
* bug.
*
* The assertion: `main.scrollHeight` ≤ bottom edge of the lowest real
* content element + the shell-owned padding + tolerance. "Real content"
* is measured against each element's CONTENT-box bottom, not its padding-
* box: a sub-shell column that re-declares its own bottom gutter (`pb-*`)
* no longer folds that gutter into `contentBottom`, so a redundant reserve
* FAILS the assertion instead of being absorbed by it. Runs across both
* two-column sub-shells — Settings AND Admin (they share the grid-floor
* source) — at desktop AND phone-shaped viewports, because the two shell
* paddings differ per breakpoint.
*/
const ROUTES = [
// Settings sub-shell — short hub, long sortable-list subpages, long form.
"/settings/layout",
"/settings/layout/dashboard",
// The other sortable-list subpages + the Modules hub: each hosts an
// order editor with the sr-only drag-hint paragraph (see the
// one-scroll-floor assertion below).
"/settings/layout/insights",
"/settings/layout/medications",
"/settings/modules/mood",
"/settings/modules",
"/settings/account",
"/settings/notifications",
// Admin sub-shell — short overview + a longer list page. Guards that the
// pre-#154 column reserve stays retired on every admin breakpoint.
"/admin",
"/admin/login-overview",
"/admin/system-status",
] as const;
const VIEWPORTS = [
{ 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;
test.describe("settings + admin vertical over-scroll guard", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(({}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"viewport-driven spec; desktop project only",
);
});
for (const vp of VIEWPORTS) {
for (const route of ROUTES) {
test(`no over-scroll on ${route} (${vp.name})`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.goto(route, { waitUntil: "domcontentloaded" });
// Was a fixed 500 ms sleep, which is the wrong shape twice over: too
// long when the page is ready and too short on a loaded runner, and
// it never says what it is waiting for. Gate on the element the
// measurement reads instead.
const wrapperLocator = page.locator(
'#main-content [data-slot="main-content-wrapper"]',
);
await settleBeforeMeasure(page, wrapperLocator);
const dims = await page.evaluate(() => {
const main = document.getElementById("main-content");
if (!main) return null;
// Selected by the stable `data-slot` (auth-shell.tsx), not by
// birth order: `firstElementChild` would resolve to whatever
// lazily-mounted sibling (portal, toast, hydrating region) landed
// first, silently measuring the wrong subtree instead of the
// real content wrapper.
const wrapper = main.querySelector<HTMLElement>(
'[data-slot="main-content-wrapper"]',
);
if (!wrapper) return null;
// Lowest content edge: max CONTENT-box bottom over the wrapper's
// visible, non-fixed descendants, in the scroll container's
// coordinate space. Using the content box (padding-box bottom
// minus the element's own bottom padding + border) means a
// layout column's own `pb-*` gutter is NOT counted — its last
// real child (a card) is still measured through the column's
// content box, so a redundant sub-shell bottom gutter shows up
// as pure over-scroll instead of inflating the allowed budget.
// Elements inside nested scroll containers are clipped by their
// own overflow and never add page scroll height, so skip
// anything whose scrollable ancestor is not `main`.
let maxBottom = 0;
const mainTop = main.getBoundingClientRect().top;
for (const el of wrapper.querySelectorAll<HTMLElement>("*")) {
const cs = getComputedStyle(el);
if (cs.position === "fixed") continue;
const rect = el.getBoundingClientRect();
if (rect.height === 0 && rect.width === 0) continue;
// Skip descendants of inner scroll containers (their
// overflow does not contribute to the page scroll height).
let p = el.parentElement;
let inner = false;
while (p && p !== main) {
const pcs = getComputedStyle(p);
if (
(pcs.overflowY === "auto" || pcs.overflowY === "scroll") &&
p.scrollHeight > p.clientHeight
) {
inner = true;
break;
}
p = p.parentElement;
}
if (inner) continue;
const padBottom = parseFloat(cs.paddingBottom) || 0;
const borderBottom = parseFloat(cs.borderBottomWidth) || 0;
const bottom =
rect.bottom - padBottom - borderBottom + main.scrollTop - mainTop;
if (bottom > maxBottom) maxBottom = bottom;
}
return {
scrollHeight: main.scrollHeight,
clientHeight: main.clientHeight,
contentBottom: Math.round(maxBottom),
};
});
expect(dims, "main-content / wrapper present").not.toBeNull();
if (!dims) return;
// One-scroll-floor (ui-standards.md §9), structural half. `<main>` may
// only be the ONLY vertical scroll surface if it also OWNS the
// containing block of its absolutely-positioned descendants: an
// abspos box with no positioned ancestor resolves against the
// initial containing block, which sits outside the scroll container,
// so `overflow-y-auto` never clips it and its static position
// extends the DOCUMENT's scrollable overflow instead.
//
// This is asserted separately from the height read below because the
// two catch the same defect at different reliabilities. The height
// read only sees an escapee that is mounted at measure time, and the
// escapees are mostly transient: a Radix `<Switch>` outside a
// `<form>` renders a hidden `position:absolute` bubble input on the
// first pass and drops it once its button ref resolves, and the root
// `loading.tsx` chart skeleton carries an `sr-only` announcement
// that lives only until the real content streams in. Both made the
// document scrollable for the whole pre-hydration window, and both
// are gone by the time a fast machine gets around to measuring, so
// the height read caught them on a loaded CI runner and nowhere
// else. The computed style of `<main>` is timing-free.
const mainPosition = await page.evaluate(
() =>
getComputedStyle(document.getElementById("main-content")!).position,
);
expect(
mainPosition,
"<main id=main-content> must establish a containing block, or an " +
"absolutely-positioned descendant escapes its overflow clip and " +
"becomes a second vertical scroll surface",
).not.toBe("static");
// Same rule, measured: the document itself must never become
// scrollable next to `<main>` (a second painted scrollbar + a dead
// dark band under the shell).
const doc = await page.evaluate(() => ({
scrollHeight: document.documentElement.scrollHeight,
clientHeight: document.documentElement.clientHeight,
}));
expect(
doc.scrollHeight,
`document scrollHeight=${doc.scrollHeight} > viewport ` +
`${doc.clientHeight} — a second vertical scroll surface exists ` +
`beside <main> (likely an absolutely-positioned element escaping ` +
`to the initial containing block)`,
).toBeLessThanOrEqual(doc.clientHeight + 1);
// A page shorter than the viewport cannot over-scroll at all.
if (dims.scrollHeight <= dims.clientHeight) return;
const tolerance = 24;
expect(
dims.scrollHeight,
`scrollHeight=${dims.scrollHeight} contentBottom=${dims.contentBottom} ` +
`expectedPad=${vp.expectedPad} — the scroll area extends past the ` +
`last content element + the shell-owned padding; some nested ` +
`min-h / bottom padding is stacking on the shell budget`,
).toBeLessThanOrEqual(dims.contentBottom + vp.expectedPad + tolerance);
});
}
}
});