Scroll the document, and let the chrome scroll with it #110

Merged
lilleman merged 10 commits from document-scroll into main 2026-09-10 16:24:03 +02:00
12 changed files with 39 additions and 170 deletions
Showing only changes of commit 53ab35bfb8 - Show all commits
+14 -17
View File
@@ -36,6 +36,12 @@ branch, create a PR and merge it when the CI/CD turns green.
## Project priorities (do not erode)
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
hold something still — a `100dvh` box, `position: sticky`/`fixed` chrome, `overflow: hidden` on
`body` — buys an app-like look with CSS the next reader has to reverse-engineer, and every such
box is one more thing to undo before the content under it can be reached. Sticky headers and
full-height panes do not earn that. The mobile off-canvas nav is the one exception, because an
overlay has no other spelling.
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.
The **host is stateless — it owns no schema and stores nothing of its own**; a plugin may own a
@@ -278,23 +284,14 @@ 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 frame is `fill: true` on the shell.** `.app` is
`min-height: 100dvh`, not a `100dvh` box with `overflow: hidden`: the sidebar is `position: sticky`
at full height and the topbar sticks with it, so the nav stays reachable — on a narrow screen the
hamburger is the only way into it, and with no script a long page would otherwise strand the reader.
Two things only the document scroller gets: **keyboard paging** unconditionally (Space, PgDn and
End reach a bounded region only once focus is inside it, which without script needs a focusable
descendant) and **scroll restoration** on back/forward. The inverse default clipped a page silently
in every engine — nothing in a test or a console says content is unreachable below the fold.
A page that is a bounded frame — a board of full-height columns, a table whose header must stay put
— sets **`fill: true`** on the shell and scrolls a region inside `.app-fill` instead. **The split is
the seam:** the shell bounds the content column and stops, because the height is the shell's to give
and a page deriving it would have to know the topbar's own; the page marks the one element that
takes that height with **`.scroll-region`** and carries the flex chain down to it, because only the
page knows its own tree. The shell must never go looking through a page for a component it
recognises — a rule keyed on `.table-wrap` works for a table the content slot holds directly and
silently clips one nested any deeper. `data-table` takes `scrollRegion: true` for
exactly this, since its wrapper is host markup a page cannot put the class on itself.
- **Nothing bounds the viewport, so there is one scroller: the document.** `.app` is
`min-height: 100dvh`, never a `100dvh` box with `overflow: hidden`. It was the latter once, and a
page taller than the window lost everything past it — silently, in every engine, because nothing in
a test or a console says content is unreachable below the fold. Only the document scroller gets
**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
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
+13 -24
View File
@@ -9,40 +9,29 @@ at boot. Entries start at 0.3.0.
**Breaking.** Set `apiVersion: "0.4.0"`. The app shell no longer bounds the content column, so a page
that relied on filling it scrolls the document instead.
### The document scrolls
### The document scrolls, and the chrome scrolls with it
`.app` was a `100dvh` box with `overflow: hidden`, so a page was only reachable below the fold if its
own wrapper was a flex child with `overflow-y: auto`. `.table-wrap` and `.shell-auth` were; nothing
else was, and a long page in `.form-page` clipped everything past the window in every engine.
Now the shell is `min-height: 100dvh` and the document scrolls. The sidebar is `position: sticky` at
full height and the topbar sticks with it, so both stay put as the page flows. Keyboard paging and
back/forward scroll restoration work without a page doing anything.
Now the shell is `min-height: 100dvh` and nothing bounds the viewport. The sidebar and topbar scroll
with the page, and keyboard paging, back/forward scroll restoration and find-in-page work without a
page doing anything.
### A bounded frame is `fill: true`, and the page says what fills it
A page whose whole point is a frame — a board of full-height columns, a table whose header must stay
put — passes `fill: true` to the shell. `.app-fill` restores the previous model: the viewport is the
page, and a region inside it scrolls.
The two halves are split on purpose. The shell bounds the content column, because the height is the
shell's to give and a page computing it would have to know the topbar's own. The page marks the
element that takes that height with `.scroll-region`, because only the page knows its own tree — a
host rule keyed on a component would work for a table held directly by the content slot and silently
clip one nested any deeper. `data-table` takes `scrollRegion: true`, which puts the class on its own wrapper.
The sticky `thead` on `data-table` goes with it: a header only sticks to a scrollport that moves, and
there is no longer one. A plugin that wants a full-height pane owns that in its own stylesheet; the
shell offers no opt-out, per the simplicity priority in `AGENTS.md`.
### Upgrading a plugin
1. Set `apiVersion: "0.4.0"`.
2. A page that scrolled the whole window needs no change — it now scrolls the document. A
`data-table` on it keeps working, but its header stops sticking; see step 4.
3. A page holding a region that filled the content column passes `fill: true` to the shell, puts
`.scroll-region` on that region, and makes every wrapper between it and the content slot a flex
column (`display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0`). Miss a wrapper and
the region grows instead of scrolling, so the frame scrolls in its place — the scrollbar moving off
the region onto the whole content column is the tell.
4. A table whose header must stay put passes `scrollRegion: true` to `data-table` on such a page.
`examples/plugins/scheduling` shows the whole chain on its shifts list.
2. A page that scrolled the whole window needs no change — it now scrolls the document.
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.
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.
## 0.3.0
+4 -10
View File
@@ -933,16 +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 of its own.
A page that is a bounded frame instead — a board of full-height columns, a table whose header must
stay put — passes **`fill: true`** to the shell, which bounds the content column. The page then says
what fills it: put **`.scroll-region`** on that element, and make every wrapper between it and the
content slot a flex column (`display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0`).
A `data-table` takes `scrollRegion: true`, which puts the class on its own wrapper — its sticky header needs
it, since a header only sticks to a scrollport that moves. `examples/plugins/scheduling` shows the
whole chain on its shifts list.
**The document scrolls, and the chrome scrolls with it.** Nothing bounds the viewport, so a page is
reachable below the fold without adding a scroll region of its own, and browser paging, scroll
restoration and find-in-page work without a page doing anything. A plugin that wants a full-height
pane owns that in its own stylesheet.
## Building blocks
-38
View File
@@ -201,44 +201,6 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page.locator("table")).not.toContainText("Morning — Front desk");
});
// `fill: true` is a contract addition whose whole behaviour is CSS, and whose effect depends on
// markup the shell does not own — so it is pinned here, on the reference plugin's own list, rather
// than by asserting the class name the shell emits.
test("plugin page: a filled page scrolls its table, not the document, and the header stays put", async () => {
// The region is the viewport less ~185px of chrome, so this leaves ~75px: enough that the 3-row
// fixture overflows it by half its height, and enough to hold the header a row scrolls under.
const original = page.viewportSize();
await page.setViewportSize({ width: 1280, height: 260 });
await page.goto("/scheduling/shifts");
const bounded = await page.evaluate(() => {
const wrap = document.querySelector(".table-wrap");
const frame = document.querySelector(".app-fill");
if (!(wrap instanceof HTMLElement) || !(frame instanceof HTMLElement)) return null;
return {
frameScrolls: frame.scrollHeight > frame.clientHeight,
regionScrolls: wrap.scrollHeight > wrap.clientHeight,
};
});
expect(bounded, ".table-wrap and .app-fill must render").not.toBeNull();
// Both halves, and both discriminating: miss a wrapper in the page's chain and the table grows
// instead of scrolling, which pushes the frame past its own height.
expect(bounded?.frameScrolls, "a filled page must fit its frame").toBe(false);
expect(bounded?.regionScrolls, "the fixture must overflow the region, or the rest proves nothing").toBe(true);
const headTop = async () => {
const box = await page.locator("thead th").first().boundingBox();
expect(box, "the header must have a box to stay put").not.toBeNull();
return box?.y;
};
const before = await headTop();
await page.locator(".table-wrap").evaluate((el) => el.scrollTo(0, el.scrollHeight));
await expect(page.locator("tbody tr").last()).toBeInViewport();
expect(await headTop(), "the header sticks to a scrollport that moves").toBe(before);
// The journey shares one page; leaving it short would hand the next test a window it never chose.
if (original) await page.setViewportSize(original);
});
test("plugin settings: the screen names the variable that sets each declared key", async () => {
await page.goto("/admin/plugin-settings");
await expect(page.locator("h1")).toHaveText("Plugin settings");
+1 -22
View File
@@ -35,7 +35,7 @@ for (const [name, path, tail] of [
["the public landing", "/", ".landing-actions .btn"],
] as const) {
for (const width of [1280, 390]) {
test(`${name} scrolls to its end at ${width}px wide, and the chrome stays put`, async ({ page }) => {
test(`${name} scrolls to its end at ${width}px wide`, async ({ page }) => {
await page.setViewportSize({ width, height: 200 });
await page.goto(path);
@@ -44,27 +44,6 @@ for (const [name, path, tail] of [
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 });
// The sticky pair is the whole reason the document may scroll: on a narrow screen the
// hamburger in the topbar is the only way back into the nav.
await expect(page.locator(".topbar")).toBeInViewport();
if (width > 860) await expect(page.locator(".brand-name")).toBeInViewport();
// The open nav is a fixed overlay, so a reader cannot scroll the page out from under the
// scrim. Focus can still move it, and stopping that needs script this page does not have.
if (width <= 860) {
await page.locator(".hamburger").click(); // the label is the control; the checkbox takes no pointer
await expect(page.locator("#nav-toggle")).toBeChecked();
// The lock is one CSS rule, and whether a key press moves the page with the nav open turns
// out to differ by engine — so pin the rule itself, then the behaviour it buys.
expect(await page.evaluate(() => getComputedStyle(document.body).overflow)).toBe("hidden");
// Both directions: opening the nav may leave the page at either end, and a key press toward
// the end it already sits at cannot move it whether the lock holds or not.
const before = await page.evaluate(() => window.scrollY);
for (const key of ["End", "Home"]) {
await page.keyboard.press(key);
expect(await page.evaluate(() => window.scrollY), key + " cannot scroll the page while the nav is open").toBe(before);
}
}
});
}
}
@@ -4,12 +4,3 @@
.scheduling-page .table-wrap {
margin-top: var(--space-3, 0.75rem);
}
/* The chain the shell cannot supply: `fill` bounds the content column, and every wrapper between it
and the table's own `.scroll-region` has to pass that height down. */
.app-fill .scheduling-page {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
+1 -2
View File
@@ -7,7 +7,7 @@
%><%
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
const filtersHtml = include("partials/filter-bar", filterBar);
const tableHtml = include("partials/data-table", { ...table, scrollRegion: true });
const tableHtml = include("partials/data-table", table);
const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : "";
const actions = canWrite
? '<a class="btn btn-primary" href="' + localeHref(newHref) + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("scheduling.shifts.new") + '</a>'
@@ -16,7 +16,6 @@
<%- include("partials/shell", {
actions,
body: '<div class="scheduling-page">' + alertHtml + filtersHtml + '<p class="shift-count">' + count + '</p>' + tableHtml + '</div>',
fill: true, // a long list scrolls under its own header rather than taking the page with it
brand: chrome.brand,
breadcrumbs,
csrfToken: chrome.csrfToken,
+3 -25
View File
@@ -57,7 +57,6 @@
/* layout + density (compact) */
--radius: 5px;
--nav-w: 264px;
--topbar-h: 48px;
--row-h: 34px;
--pad-x: 12px;
--fz: 13px;
@@ -148,7 +147,6 @@ summary { list-style: none; cursor: pointer; }
.ico-sm { width: 14px; height: 14px; }
/* ---------- 3. APP GRID ------------------------------------- */
/* The document scrolls; `fill` opts out — see .app-fill (AGENTS.md → UI). */
.app {
display: grid;
grid-template-columns: var(--nav-w) minmax(0, 1fr);
@@ -158,7 +156,6 @@ summary { list-style: none; cursor: pointer; }
/* ---------- 4. SIDEBAR -------------------------------------- */
.sidebar {
grid-column: 1;
position: sticky; top: 0; height: 100dvh; align-self: start;
display: flex;
flex-direction: column;
background: var(--surface);
@@ -166,7 +163,7 @@ summary { list-style: none; cursor: pointer; }
}
.brand {
display: flex; align-items: center; gap: 10px;
height: var(--topbar-h); padding: 0 14px; flex: 0 0 auto;
height: 48px; padding: 0 14px; flex: 0 0 auto;
border-bottom: 1px solid var(--border);
}
.brand-mark {
@@ -337,14 +334,12 @@ span.nav-self { cursor: default; } /* static / non-clickable */
/* topbar (page title + hamburger on mobile) */
.topbar {
position: sticky; top: 0; z-index: 20;
flex: 0 0 auto; height: var(--topbar-h);
flex: 0 0 auto; height: 48px;
display: flex; align-items: center; gap: 12px;
padding: 0 16px; border-bottom: 1px solid var(--border);
background: var(--surface);
}
.hamburger { display: none; } /* shown only on narrow */
.content :target, .content :focus-visible { scroll-margin-top: var(--topbar-h); }
.page-title { margin: 0; font-weight: 600; letter-spacing: -.01em; font-size: 14px; }
.page-sub { color: var(--text-faint); font-size: var(--fz-sm); }
.topbar-spacer { flex: 1 1 auto; }
@@ -562,13 +557,12 @@ span.nav-self { cursor: default; } /* static / non-clickable */
/* ---------- 9. TABLE --------------------------------------- */
/* A bounded region is opt-in: give this a height and the header below stays put inside it. */
.table-wrap { overflow: auto; }
.table-wrap { overflow-x: auto; }
table.table {
width: 100%; border-collapse: separate; border-spacing: 0;
font-size: var(--fz); font-variant-numeric: tabular-nums;
}
.table thead th {
position: sticky; top: 0; z-index: 10;
background: var(--surface-3);
border-bottom: 1px solid var(--border-2);
color: var(--text-muted); font-weight: 600; font-size: var(--fz-xs);
@@ -693,8 +687,6 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
background: rgba(0,0,0,.42);
}
.search { min-width: 150px; flex: 1 1 auto; }
/* The open nav is a fixed overlay: a reader scrolling would move what the scrim covers. */
body:has(#nav-toggle:checked) { overflow: hidden; }
}
@media (max-width: 560px) {
.crumbs, .page-sub { display: none; }
@@ -728,19 +720,5 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
.app-bare { grid-template-columns: minmax(0, 1fr); }
.app-bare .content { grid-column: 1; }
/* The shell's half of `fill: true` (AGENTS.md → UI). `auto`, not `hidden`: a page that misses a
wrapper in its own chain then scrolls here instead of losing the overflow. */
.app-fill { height: 100dvh; overflow: auto; }
.app-fill .content { min-height: 0; }
/* The page's half: `min-height: 0` is the one everyone forgets, since `auto` floors a flex item at
its content. */
.scroll-region { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
@media print {
/* A bounded frame prints as one sheet otherwise, losing everything the region scrolls past. */
.app-fill { height: auto; overflow: visible; }
.scroll-region { overflow: visible; }
}
/* Auth/landing rendered inside the app shell: a roomy, centered column in the content area. */
.shell-auth { flex: 1 1 auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; }
-3
View File
@@ -42,9 +42,6 @@ test("data-table renders sortable headers, row-select, typed cells, badges and k
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>/);
// Opt-in, and only then: the class is what a filled page hands its leftover height to.
assert.match(flat(await render({ ...config, scrollRegion: true })), /<div class="table-wrap scroll-region">/);
// 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, /<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Mara Delgado"><\/td>/);
-10
View File
@@ -95,20 +95,10 @@ test("app shell can disable the menu: no sidebar, focused single-column layout",
assert.doesNotMatch(bare, /<aside class="sidebar"/); // sidebar dropped
assert.doesNotMatch(bare, /class="hamburger"/); // and its mobile toggle
assert.match(bare, /<div class="app app-bare">/); // single-column variant
assert.doesNotMatch(bare, /app-fill/); // the document scrolls unless a page says otherwise
assert.match(bare, /<main class="content" id="main-content"/); // content still renders
assert.match(bare, /<section id="b">x<\/section>/);
});
test("app shell: fill is opt-in and composes with menu:false; what the class does is pinned in e2e", async () => {
// What the class does depends on the page's own flex chain (AGENTS.md → UI), so full-flow holds
// that; this holds only that nothing opts a page in by accident.
const filled = await render({ fill: true, title: "Board", body: "<div>x</div>", nav: "" });
assert.match(filled, /<div class="app app-fill">/);
const bothOff = await render({ fill: true, menu: false, title: "Board", body: "<div>x</div>", nav: "" });
assert.match(bothOff, /<div class="app app-bare app-fill">/);
});
test("app shell: an empty title yields no topbar <h1> so the body owns the single heading; docTitle sets <title>", async () => {
// Auth/landing pass title:"" (their card/hero is the <h1>) + an explicit docTitle for the tab.
const html = await render({ title: "", docTitle: "Sign in", brand: { name: "Acme" }, body: "<h1>Sign in</h1>" });
+1 -5
View File
@@ -5,9 +5,6 @@
caption?, selectable?, actions? sr-only caption; toggle the check / kebab columns
actionsId? id stem for the row-action menus (default `row-actions`);
name it when two tables share a page
scrollRegion? take the leftover height and scroll, so the header stays
put — needs the shell's `fill: true` and a flex chain
above (README → The menu system)
columns: { label, sortable?, sort?: "asc"|"desc", href?, className? }[]
rows: { name?, cells: Cell[], actions?: Action[] }[]
Cell ∈ string | { text, className? } | { user:{name,initials} } | { rowHeader:{text,href?} } | { badge:{tone,label} } | { html, className? }
@@ -23,9 +20,8 @@
const rows = locals.rows || [];
const emptyText = locals.emptyText || t("table.empty"); // shown when a table that has columns has no rows
const actionsId = locals.actionsId || "row-actions";
const scrollRegion = locals.scrollRegion === true;
-%>
<div class="table-wrap<%= scrollRegion ? " scroll-region" : "" %>">
<div class="table-wrap">
<table class="table">
<% if (caption) { -%>
<caption class="sr-only"><%= caption %></caption>
+2 -5
View File
@@ -9,16 +9,13 @@
(the <title> tag; defaults to title or the brand), `brand` ({ name, logo?, sub? }), `theme`
(theme-switch default), `user`, `breadcrumbs`, `csrfToken` (the Sign-out form's hidden field),
`signInHref` (anonymous "Sign in" target; default /login). `menu` (default true) — set false to
drop the sidebar and render a focused single-column page. `fill` (default false) — set true when the
page is a bounded frame whose own region scrolls (a board, a table with a fixed header) rather than
the document. `t`, `locale`, `dir` and `localeSwitch`
drop the sidebar and render a focused single-column page. `t`, `locale`, `dir` and `localeSwitch`
come from the host with every render (README → Languages).
%><%
const brand = locals.brand || { name: "Plainpages" };
const title = locals.title || ""; // topbar heading; empty ⇒ no topbar <h1> (the body owns it)
const docTitle = locals.docTitle || title || brand.name;
const menu = locals.menu !== false; // sidebar shown by default; a page may opt out
const fill = locals.fill === true; // the document scrolls unless a page says it owns the viewport
const hideSignIn = locals.hideSignIn === true; // the auth pages are already a way in — no footer Sign-in there
const user = locals.user || { name: "Guest", initials: "G", email: "" };
const breadcrumbs = locals.breadcrumbs || [];
@@ -44,7 +41,7 @@
<input type="checkbox" id="nav-toggle" aria-hidden="true" tabindex="-1" />
<% } %>
<div class="app<%= menu ? "" : " app-bare" %><%= fill ? " app-fill" : "" %>">
<div class="app<%= menu ? "" : " app-bare" %>">
<% if (menu) { %>
<aside class="sidebar" aria-label="<%= t("shell.sidebar") %>">
<div class="brand">