Keep the reader in place when the drawer closes, and stop the CSS promising a header it deleted
CI / full-gate (push) Successful in 2m54s

This commit is contained in:
2026-09-09 15:52:46 +02:00
parent f959404c6f
commit 8128ec2f42
5 changed files with 44 additions and 20 deletions
+13 -14
View File
@@ -36,12 +36,15 @@ branch, create a PR and merge it when the CI/CD turns green.
## Project priorities (do not erode) ## Project priorities (do not erode)
1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable. 1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable.
**A page is a document**: it scrolls, and the chrome scrolls with it. Bounding the viewport to **A page is a document**: it scrolls, and the chrome scrolls with it. Nothing may bound the
hold something still — a `100dvh` box, `position: sticky`/`fixed` chrome, `overflow: hidden` on viewport to hold content still — no `height: 100dvh` frame, no `overflow: hidden` on `body`, no
`body` buys an app-like look with CSS the next reader has to reverse-engineer, and every such `position: sticky` header. Each such box buys an app-like look with CSS the next reader has to
box is one more thing to undo before the content under it can be reached. Sticky headers and reverse-engineer, and is one more thing to undo before the content under it can be reached.
full-height panes do not earn that. The mobile off-canvas nav is the one exception, because an Overlays are not this: the skip link, the mobile off-canvas nav and its scrim sit *above* the
overlay has no other spelling. document rather than holding it still, and have no other spelling — the document keeps scrolling
behind the open nav, accepted rather than overlooked. Only the document scroller gets keyboard
paging unconditionally and back/forward scroll restoration, and a page a box clips fails silently:
nothing in a test or a console says content is unreachable below the fold.
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`, 2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`,
`postgres`). Prefer the Node standard library; justify any new dependency; do not add frameworks. `postgres`). Prefer the Node standard library; justify any new dependency; do not add frameworks.
The **host is stateless — it owns no schema and stores nothing of its own**; a plugin may own a The **host is stateless — it owns no schema and stores nothing of its own**; a plugin may own a
@@ -284,14 +287,10 @@ 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 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 profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep
the two in step. the two in step.
- **Nothing bounds the viewport, so there is one scroller: the document.** `.app` is - **One scroller, the document** (priority 1). `.app` is `min-height: 100dvh`. `.nav`'s
`min-height: 100dvh`, never a `100dvh` box with `overflow: hidden`. It was the latter once, and a `overflow-y: auto` and `.side-footer`'s `flex: 0 0 auto` are not leftovers of a bounded frame:
page taller than the window lost everything past it — silently, in every engine, because nothing in they are what makes the off-canvas panel usable with a long tree. `#nav-toggle` is `position: fixed`
a test or a console says content is unreachable below the fold. Only the document scroller gets for the same reason — a label click focuses it, and a browser scrolls a focused element into view.
**keyboard paging** unconditionally and **scroll restoration** on back/forward; a bounded region
gets keys only once focus is inside it, which without script needs a focusable descendant.
A plugin that wants a full-height pane owns that in its own stylesheet — the shell offers no opt-out
and no `.table thead` stickiness, per priority 1.
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it - **`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 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 to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an
+4 -1
View File
@@ -30,8 +30,11 @@ shell offers no opt-out, per the simplicity priority in `AGENTS.md`.
3. A page holding a region that filled the content column (`flex: 1 1 auto; min-height: 0` with its 3. A page holding a region that filled the content column (`flex: 1 1 auto; min-height: 0` with its
own `overflow`) no longer gets a bounded column to fill, so that region grows and the page scrolls. own `overflow`) no longer gets a bounded column to fill, so that region grows and the page scrolls.
Either let it, or give the region its own height in the plugin's stylesheet. Either let it, or give the region its own height in the plugin's stylesheet.
4. A `data-table` keeps working; its header no longer stays put while the rows scroll. 4. A `data-table` no longer scrolls its rows in a bounded region: the page scrolls, and the header
scrolls with it.
The sidebar stretches the whole document, so on a long page its footer — theme, language, profile and
**Sign out** — sits at the end of that page rather than the bottom of the screen.
## 0.3.0 ## 0.3.0
+25 -3
View File
@@ -28,8 +28,8 @@ test.beforeEach(async ({ context }) => {
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]); await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
}); });
// A key press, not scrollIntoView a script can scroll an overflow-hidden box, a reader cannot; // A key press, not scrollIntoView (a script can scroll a box no reader can) and not the wheel
// and not the wheel, which Firefox's synthetic event never delivers to the document. // (Firefox's synthetic event never reaches the document).
for (const [name, path, tail] of [ for (const [name, path, tail] of [
["the starter dashboard", "/dashboard", ".form-actions .btn"], ["the starter dashboard", "/dashboard", ".form-actions .btn"],
["the public landing", "/", ".landing-actions .btn"], ["the public landing", "/", ".landing-actions .btn"],
@@ -42,12 +42,34 @@ for (const [name, path, tail] of [
const overflows = await page.evaluate(() => document.documentElement.scrollHeight > window.innerHeight); const overflows = await page.evaluate(() => document.documentElement.scrollHeight > window.innerHeight);
expect(overflows, "the page must overflow, or it proves nothing").toBe(true); expect(overflows, "the page must overflow, or it proves nothing").toBe(true);
await page.keyboard.press("End"); await page.keyboard.press("End");
// Whole, not merely touched: a control half under the fold is not reachable either.
await expect(page.locator(tail).last()).toBeInViewport({ ratio: 1 }); await expect(page.locator(tail).last()).toBeInViewport({ ratio: 1 });
}); });
} }
} }
// The drawer is an overlay, so the document keeps scrolling behind it. Closing it must then leave
// the reader where they were: the toggle is focusable and its label is what a tap hits, and a
// browser scrolls a focused element into view — from the top of the document that meant jumping
// there. `#nav-toggle` is `position: fixed` so there is nothing to scroll to.
test("closing the mobile drawer leaves the reader where the scrim found them", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 200 });
await page.goto("/dashboard");
await page.locator(".hamburger").click();
await expect(page.locator("#nav-toggle")).toBeChecked();
// Scripted, because a key press with focus on the toggle does not scroll in every engine — and
// what is under test is closing the drawer, not how the reader got down the page.
await page.evaluate(() => window.scrollTo(0, 120));
const at = await page.evaluate(() => window.scrollY);
expect(at, "the page must have somewhere to scroll behind the scrim").toBeGreaterThan(0);
// The exposed strip beside the 264px panel: the scrim spans the viewport, so its centre is under
// the drawer and a centre click lands on the panel instead.
await page.locator(".scrim").click({ position: { x: 340, y: 100 } });
await expect(page.locator("#nav-toggle")).not.toBeChecked();
expect(await page.evaluate(() => window.scrollY), "closing the drawer must not move the page").toBe(at);
});
test("captures the live pages for review", async ({ page }) => { test("captures the live pages for review", async ({ page }) => {
await page.goto("/dashboard"); await page.goto("/dashboard");
await expect(page.locator(".sidebar")).toBeVisible(); await expect(page.locator(".sidebar")).toBeVisible();
+1 -2
View File
@@ -556,7 +556,6 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.pill-clear:hover { text-decoration: underline; } .pill-clear:hover { text-decoration: underline; }
/* ---------- 9. TABLE --------------------------------------- */ /* ---------- 9. TABLE --------------------------------------- */
/* A bounded region is opt-in: give this a height and the header below stays put inside it. */
.table-wrap { overflow-x: auto; } .table-wrap { overflow-x: auto; }
table.table { table.table {
width: 100%; border-collapse: separate; border-spacing: 0; width: 100%; border-collapse: separate; border-spacing: 0;
@@ -694,7 +693,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
} }
/* the nav-toggle checkbox itself is visually hidden but focusable */ /* the nav-toggle checkbox itself is visually hidden but focusable */
#nav-toggle { position: absolute; opacity: 0; pointer-events: none; } #nav-toggle { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; }
/* admin forms: create/edit user, account actions */ /* admin forms: create/edit user, account actions */
.form-page { padding: 16px; display: flex; flex-direction: column; gap: 14px; max-width: 560px; } .form-page { padding: 16px; display: flex; flex-direction: column; gap: 14px; max-width: 560px; }
+1
View File
@@ -42,6 +42,7 @@ test("data-table renders sortable headers, row-select, typed cells, badges and k
const html = flat(await render(config)); const html = flat(await render(config));
assert.match(html, /<div class="table-wrap"><table class="table"><caption class="sr-only">People in the directory<\/caption>/); assert.match(html, /<div class="table-wrap"><table class="table"><caption class="sr-only">People in the directory<\/caption>/);
// Row-select: header select-all + per-row checkbox with a descriptive label. // Row-select: header select-all + per-row checkbox with a descriptive label.
assert.match(html, /<th class="col-check" scope="col"><input type="checkbox" aria-label="Select all rows"><\/th>/); assert.match(html, /<th class="col-check" scope="col"><input type="checkbox" aria-label="Select all rows"><\/th>/);
assert.match(html, /<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Mara Delgado"><\/td>/); assert.match(html, /<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Mara Delgado"><\/td>/);