diff --git a/AGENTS.md b/AGENTS.md
index 9b3f361..f194e1f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -278,16 +278,22 @@ 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.
+- **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.
+ Three 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), **scroll restoration** on back/forward, and find-in-page. 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 height is
+ the shell's to give, not the page's to compute: a page deriving it would have to know the topbar's
+ own height, which is a reach-through, so `.table-wrap` is `overflow-x: auto` and takes its vertical
+ scroll from `.app-fill`. The `visual.spec.ts` scroll test presses **End** rather than sending a
+ wheel event (Firefox's synthetic wheel does not reach the document) and rather than
+ `scrollIntoView`, which a script can apply to an overflow-hidden box that no reader can scroll.
- **`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/CHANGELOG.md b/CHANGELOG.md
index 9830c88..4fb7cc7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,38 @@ The release version **is** the plugin contract version (`HOST_API_VERSION`), so
contract break: a plugin's `apiVersion` must match the host's `major.minor` or discovery refuses it
at boot. Entries start at 0.3.0.
+## 0.4.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
+
+`.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, back/
+forward scroll restoration and find-in-page work without a page doing anything.
+
+### A bounded frame is `fill: true`
+
+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. `data-table`'s sticky `thead` needs it, since a header sticks
+only to a scrollport that moves.
+
+The height is the shell's to give: a page computing it would have to know the topbar's own height.
+
+### 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.
+3. A page holding a region that filled the content column (`flex: 1 1 auto; min-height: 0` with its
+ own `overflow`) passes `fill: true` to the shell; the region then works as before.
+4. A table whose header must stay put needs `fill: true` on that page.
+
## 0.3.0
**Breaking.** Set `apiVersion: "0.3.0"`, and name a gate on every route and nav node.
diff --git a/README.md b/README.md
index 54ee342..ff0ff42 100644
--- a/README.md
+++ b/README.md
@@ -47,7 +47,7 @@ folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.
import { definePlugin } from "@plainpages/plugin-api";
export default definePlugin({
- apiVersion: "0.3.0",
+ apiVersion: "0.4.0",
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
routes: [
{ method: "GET", path: "/", public: true, handler: () => ({ html: "
Hello from my plugin
" }) },
@@ -350,7 +350,7 @@ import { definePlugin } from "@plainpages/plugin-api";
import { listThings, createThings } from "./handlers.ts";
export default definePlugin({
- apiVersion: "0.3.0", // semver string of the host contract this plugin was built against (see Versioning)
+ apiVersion: "0.4.0", // semver string of the host contract this plugin was built against (see Versioning)
// Nav fragment, merged into the global menu and gate-filtered per user.
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
@@ -471,7 +471,7 @@ import { definePlugin } from "@plainpages/plugin-api";
import { landing, board } from "./pages.ts";
export default definePlugin({
- apiVersion: "0.3.0",
+ apiVersion: "0.4.0",
home: landing, // owns "/" — the public front page
dashboard: board, // owns "/dashboard" — the post-login app home
});
@@ -757,7 +757,7 @@ camel humps both becoming underscores — so `upstream` on the `scheduling` plug
```ts
export default definePlugin({
- apiVersion: "0.3.0",
+ apiVersion: "0.4.0",
settings: [
{ key: "upstream", type: "url", required: true, description: "Base URL of the backend" },
{ key: "pageSize", type: "number", default: 25 },
@@ -811,7 +811,7 @@ import { definePlugin } from "@plainpages/plugin-api";
let sql: ReturnType;
export default definePlugin({
- apiVersion: "0.3.0",
+ apiVersion: "0.4.0",
storage: true,
hooks: {
onBoot: async (boot) => {
@@ -934,8 +934,10 @@ anonymous visitor. The sidebar collapses to a burger on a narrow screen; a page
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`.
+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: the viewport becomes the page, and a region inside it scrolls. `data-table`'s sticky header
+needs it, since a header can only stick to a scrollport that moves.
## Building blocks
diff --git a/e2e-tests/visual.spec.ts b/e2e-tests/visual.spec.ts
index 5d87907..403bf01 100644
--- a/e2e-tests/visual.spec.ts
+++ b/e2e-tests/visual.spec.ts
@@ -29,18 +29,31 @@ test.beforeEach(async ({ context }) => {
});
// 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");
+// through the document. One page per body idiom, since the change removed a bounded rule from each
+// (.form-page never had one, .shell-auth did). A key press, not scrollIntoView — a script can scroll
+// an overflow-hidden box, a reader cannot; and not the wheel, which Firefox's synthetic event never
+// delivers to the document.
+for (const [name, path, tail] of [
+ ["the starter dashboard", "/dashboard", ".form-actions .btn"],
+ ["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 }) => {
+ await page.setViewportSize({ width, height: 200 });
+ await page.goto(path);
- const last = page.locator(".form-actions .btn").last();
- await expect(last).not.toBeInViewport();
- await page.keyboard.press("End");
- await expect(last).toBeInViewport();
-});
+ const overflows = await page.evaluate(() => document.documentElement.scrollHeight > window.innerHeight);
+ expect(overflows, "the page must overflow, or it proves nothing").toBe(true);
+ 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();
+ });
+ }
+}
test("captures the live pages for review", async ({ page }) => {
await page.goto("/dashboard");
diff --git a/examples/plugins/admin/plugin.ts b/examples/plugins/admin/plugin.ts
index d5ce0c6..6a407c0 100644
--- a/examples/plugins/admin/plugin.ts
+++ b/examples/plugins/admin/plugin.ts
@@ -28,7 +28,7 @@ const clients = on("oauth2-clients");
const pluginSettings = on("plugin-settings");
export default definePlugin({
- apiVersion: "0.3.0", // the host contract this was built against — a literal, never HOST_API_VERSION
+ apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION
nav: [ADMIN_NAV],
diff --git a/examples/plugins/scheduling/plugin.ts b/examples/plugins/scheduling/plugin.ts
index cb01632..215a24d 100644
--- a/examples/plugins/scheduling/plugin.ts
+++ b/examples/plugins/scheduling/plugin.ts
@@ -12,7 +12,7 @@ let upstreamUrl = "";
const upstream = createUpstream(() => upstreamUrl);
export default definePlugin({
- apiVersion: "0.3.0", // the host contract this was built against — a literal, never HOST_API_VERSION
+ apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION
// onBoot runs after discovery, before the server listens — where a plugin receives its resolved
// settings. A malformed URL already failed the boot by then; the host validated the declared type.
diff --git a/public/css/styles.css b/public/css/styles.css
index b7e7d63..c59a837 100644
--- a/public/css/styles.css
+++ b/public/css/styles.css
@@ -57,6 +57,7 @@
/* layout + density (compact) */
--radius: 5px;
--nav-w: 264px;
+ --topbar-h: 48px;
--row-h: 34px;
--pad-x: 12px;
--fz: 13px;
@@ -149,7 +150,7 @@ summary { list-style: none; cursor: pointer; }
/* ---------- 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). */
+ bringing a scroll region of its own. `fill` opts out — see .app-fill (AGENTS.md → UI). */
.app {
display: grid;
grid-template-columns: var(--nav-w) minmax(0, 1fr);
@@ -167,7 +168,7 @@ summary { list-style: none; cursor: pointer; }
}
.brand {
display: flex; align-items: center; gap: 10px;
- height: 48px; padding: 0 14px; flex: 0 0 auto;
+ height: var(--topbar-h); padding: 0 14px; flex: 0 0 auto;
border-bottom: 1px solid var(--border);
}
.brand-mark {
@@ -339,12 +340,13 @@ 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: 48px;
+ flex: 0 0 auto; height: var(--topbar-h);
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; }
@@ -693,6 +695,8 @@ 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: scrolling the page behind it moves what the scrim covers. */
+ body:has(#nav-toggle:checked) { overflow: hidden; }
}
@media (max-width: 560px) {
.crumbs, .page-sub { display: none; }
@@ -725,5 +729,13 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
/* Chromeless shell: a page may drop the sidebar for a focused single column. */
.app-bare { grid-template-columns: minmax(0, 1fr); }
.app-bare .content { grid-column: 1; }
+
+/* `fill: true` on the shell: the viewport is the page, and a region inside it scrolls instead of the
+ document. For a page whose whole point is a bounded frame — a board of full-height columns, a table
+ whose header must stay put. The height is the shell's to give: a page computing it would have to
+ know the topbar's own. */
+.app-fill { height: 100dvh; overflow: hidden; }
+.app-fill .content { min-height: 0; }
+.app-fill .table-wrap { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
/* 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; }
diff --git a/release-tooling/contract-version.test.ts b/release-tooling/contract-version.test.ts
index 65fec87..df5b356 100644
--- a/release-tooling/contract-version.test.ts
+++ b/release-tooling/contract-version.test.ts
@@ -12,7 +12,7 @@ test("readHostApiVersion pulls the constant out of the real source, and returns
test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => {
// Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that
// makes an accidental edit fail here rather than at release time.
- assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.3.0");
+ assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.4.0");
});
test("every author-facing apiVersion sample matches the shipped contract", () => {
diff --git a/release-tooling/dockerhub-overview.md.tmpl b/release-tooling/dockerhub-overview.md.tmpl
index b879d55..eb5315a 100644
--- a/release-tooling/dockerhub-overview.md.tmpl
+++ b/release-tooling/dockerhub-overview.md.tmpl
@@ -182,7 +182,7 @@ into the app. Create `plugins/hello/plugin.ts`:
import { definePlugin } from "@plainpages/plugin-api";
export default definePlugin({
- apiVersion: "0.3.0",
+ apiVersion: "0.4.0",
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
routes: [
{ method: "GET", path: "/", public: true, handler: () => ({ html: "Hello from my plugin
" }) },
diff --git a/src/plugin-host/plugin.ts b/src/plugin-host/plugin.ts
index cb5fdb6..4c4716f 100644
--- a/src/plugin-host/plugin.ts
+++ b/src/plugin-host/plugin.ts
@@ -11,7 +11,7 @@ import { envName, type SettingDecl, type SettingsOf } from "./settings.ts";
import type { StorageCredentials } from "./storage.ts";
// The Plainpages release this contract ships in — see README → Contract versioning.
-export const HOST_API_VERSION = "0.3.0";
+export const HOST_API_VERSION = "0.4.0";
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
diff --git a/src/ui/shell.test.ts b/src/ui/shell.test.ts
index b062e73..5768bdf 100644
--- a/src/ui/shell.test.ts
+++ b/src/ui/shell.test.ts
@@ -95,10 +95,20 @@ test("app shell can disable the menu: no sidebar, focused single-column layout",
assert.doesNotMatch(bare, /