From b9129fba08b309b757da04ae60281e3de5939a9b Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 7 Sep 2026 07:57:33 +0200 Subject: [PATCH] Scroll the document by default; a bounded region is a page's own opt-in --- AGENTS.md | 10 ++++++++++ README.md | 4 ++++ e2e-tests/visual.spec.ts | 14 ++++++++++++++ public/css/styles.css | 17 ++++++++++------- 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ca83fd3..9b3f361 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -278,6 +278,16 @@ Revisit only if the stated reason stops holding. it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep the two in step. +- **The document scrolls; a bounded scroll region is opt-in.** `.app` is `min-height: 100dvh`, not + a `100dvh` box with `overflow: hidden`: the sidebar is `position: sticky` at full height and the + topbar sticks too, so both stay put while the content column flows with the page. A page that wants + a region scrolling inside it — a board of full-height columns, a table whose header stays put — + gives that region a height and scrolls within it; `.table-wrap` is `overflow-x: auto` and nothing + more until a page does. The inverse default clipped the first long page that did not know the rule + (2026-09-06), silently, in every engine: nothing in a test or a console says a page is unreachable + below the fold. Document scrolling is also what keeps find-in-page, anchor links, keyboard paging, + print and reader mode working. The `visual.spec.ts` wheel test holds this; scrollIntoView would not, + since a script can scroll an overflow-hidden box and a reader cannot. - **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it is deliberately not re-exported from `@plainpages/plugin-api`. The palette may narrow when the last reference to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an diff --git a/README.md b/README.md index 52ca628..54ee342 100644 --- a/README.md +++ b/README.md @@ -933,6 +933,10 @@ recovery / front pages — so it looks identical signed in or out and just shows anonymous visitor. The sidebar collapses to a burger on a narrow screen; a page wanting a chrome-free layout opts out with the shell's `menu: false`. +**The document scrolls.** The sidebar and topbar stay put on their own, and a page is reachable +below the fold without adding a scroll region. A region that should scroll *inside* the page — a +board of full-height columns, a table whose header stays put — sets its own height and `overflow`. + ## Building blocks Plainpages is a **component library, not a page generator** — reusable EJS partials + TS helpers, diff --git a/e2e-tests/visual.spec.ts b/e2e-tests/visual.spec.ts index 01c2720..5d87907 100644 --- a/e2e-tests/visual.spec.ts +++ b/e2e-tests/visual.spec.ts @@ -28,6 +28,20 @@ test.beforeEach(async ({ context }) => { await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]); }); +// The shell must never clip a page: a body that does not scroll itself has to reach the reader +// through the document. A key press, not scrollIntoView — a script can scroll an overflow-hidden +// box, a reader cannot. End rather than the wheel: Firefox's synthetic wheel never reaches the +// document. +test("a page taller than the window scrolls, so its last control can be reached", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 240 }); + await page.goto("/dashboard"); + + const last = page.locator(".form-actions .btn").last(); + await expect(last).not.toBeInViewport(); + await page.keyboard.press("End"); + await expect(last).toBeInViewport(); +}); + test("captures the live pages for review", async ({ page }) => { await page.goto("/dashboard"); await expect(page.locator(".sidebar")).toBeVisible(); diff --git a/public/css/styles.css b/public/css/styles.css index 9ec5091..b7e7d63 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -111,7 +111,7 @@ html:has(#theme-light:checked) { /* ---------- 2. RESET ---------------------------------------- */ *, *::before, *::after { box-sizing: border-box; } -html, body { height: 100%; } +html, body { min-height: 100%; } body { margin: 0; background: var(--bg); color: var(--text); -webkit-font-smoothing: antialiased; } button { font: inherit; color: inherit; } @@ -148,19 +148,20 @@ summary { list-style: none; cursor: pointer; } .ico-sm { width: 14px; height: 14px; } /* ---------- 3. APP GRID ------------------------------------- */ +/* Not a viewport-height box: the document scrolls, so a page is reachable below the fold without + bringing a scroll region of its own (AGENTS.md → UI). */ .app { display: grid; grid-template-columns: var(--nav-w) minmax(0, 1fr); - height: 100dvh; - overflow: hidden; + min-height: 100dvh; } /* ---------- 4. SIDEBAR -------------------------------------- */ .sidebar { grid-column: 1; + position: sticky; top: 0; height: 100dvh; align-self: start; display: flex; flex-direction: column; - min-height: 0; background: var(--surface); border-right: 1px solid var(--border); } @@ -331,12 +332,13 @@ span.nav-self { cursor: default; } /* static / non-clickable */ .content { grid-column: 2; display: flex; flex-direction: column; - min-width: 0; min-height: 0; + min-width: 0; background: var(--bg); } /* topbar (page title + hamburger on mobile) */ .topbar { + position: sticky; top: 0; z-index: 20; flex: 0 0 auto; height: 48px; display: flex; align-items: center; gap: 12px; padding: 0 16px; border-bottom: 1px solid var(--border); @@ -559,7 +561,8 @@ span.nav-self { cursor: default; } /* static / non-clickable */ .pill-clear:hover { text-decoration: underline; } /* ---------- 9. TABLE --------------------------------------- */ -.table-wrap { flex: 1 1 auto; min-height: 0; overflow: auto; } +/* A bounded region is opt-in: give this a height and the header below stays put inside it. */ +.table-wrap { overflow-x: auto; } table.table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: var(--fz); font-variant-numeric: tabular-nums; @@ -723,4 +726,4 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); } .app-bare { grid-template-columns: minmax(0, 1fr); } .app-bare .content { grid-column: 1; } /* Auth/landing rendered inside the app shell: a roomy, centered column in the content area. */ -.shell-auth { flex: 1 1 auto; overflow-y: auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; } +.shell-auth { flex: 1 1 auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; }