Give the scroll container its own containing block

`<main id="main-content">` scrolls the authenticated shell, but it was
`position: static`, so an absolutely-positioned descendant with no nearer
positioned ancestor resolved against the initial containing block. That
block sits outside the scroll container: `overflow-y-auto` never clipped
those boxes, and their static position extended the DOCUMENT's scrollable
overflow instead. The result is a second vertical scroll surface beside
`<main>`, which UI-STANDARDS §9 forbids.

The escapees are library markup, so no per-component wrapper would have
caught them. A Radix `<Switch>` outside a `<form>` renders a hidden
`position:absolute` bubble input on its first pass and drops it only once
the button ref resolves, and the root `loading.tsx` chart skeleton carries
an `sr-only` announcement that lives until the real content streams in.
Both are gone within a few hundred milliseconds of hydration, so the page
carried a full-height document scrollbar for the whole pre-hydration
window and looked clean immediately after. On /settings/modules at a
390x844 viewport the document measured 1952 px against an 844 px viewport
while the switches were still bubble-inputs.

Anchoring the scroll container closes the escape route for every such box
at once. The over-scroll guard grows the matching structural assertion:
`<main>` must not compute to `position: static`. The height read it
already had can only see an escapee that happens to be mounted at measure
time, which is why it went red on a loaded CI runner and green on the same
tree twenty minutes earlier; the computed style is timing-free and fails
on every route and both viewports the moment the anchor is removed.
This commit is contained in:
Marc-André Bombeck committed 2026-08-01 21:24:24 +02:00
1 parent 6d4eefe26b
commit 1b7dff411a
2 files changed
+53 -10

No files matched your search

+34 -9
View File
@@ -135,15 +135,40 @@ test.describe("settings + admin vertical over-scroll guard", () => {
expect(dims, "main-content / wrapper present").not.toBeNull();
if (!dims) return;
// One-scroll-floor (UI-STANDARDS §9): `<main>` is the ONLY vertical
// scroll surface — the document itself must never become scrollable
// next to it (a second painted scrollbar + a dead dark band under
// the shell). The historic offender class: an absolutely-positioned
// sr-only element (the dnd drag-hint paragraph) whose containing
// block resolves to the initial containing block because no ancestor
// between it and the root is positioned — its static position below
// a long list then extends the DOCUMENT's scrollable overflow while
// `<main>` clips everything in normal flow.
// One-scroll-floor (UI-STANDARDS §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,
+19 -1
View File
@@ -319,7 +319,25 @@ export function AuthShell({
// wrapper then recentres and the whole column, including the
// admin/settings sidebar, shifts a few px sideways on every
// toggle. Reserving the gutter holds the layout still.
className="flex-1 [scrollbar-gutter:stable] overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0"
//
// `relative` makes this element the containing block for every
// absolutely-positioned descendant that has no nearer positioned
// ancestor. Without it those boxes resolve against the INITIAL
// containing block, which lives outside the scroll container, so
// `overflow-y-auto` never clips them: their static position sits
// wherever the flow put them and extends the DOCUMENT's
// scrollable overflow instead, painting a second vertical scroll
// surface beside `<main>` (UI-STANDARDS §9 one-scroll-floor).
// The class is not hypothetical and not confined to app code. A
// Radix `<Switch>` outside a `<form>` renders a hidden
// `position:absolute` bubble input on the first pass and drops it
// only once the button ref resolves, so every route carrying a
// switch below the fold (/settings/modules is the long one) grew
// a full-height document scrollbar for the entire pre-hydration
// window. Anchoring the scroll container closes the escape route
// for all of them at once instead of asking each new
// absolutely-positioned child to remember its own wrapper.
className="relative flex-1 [scrollbar-gutter:stable] overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0"
>
{/*
v1.4.33 IW9 — container normalised on `max-w-screen-xl`