From 11ba843ab19e4f0d118ce921e33bbd7797e0f459 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 8 Sep 2026 17:27:21 +0200 Subject: [PATCH] Name the bounded frame: fill:true on the shell, and the contract minor that ships it --- AGENTS.md | 26 +++++++++------- CHANGELOG.md | 32 ++++++++++++++++++++ README.md | 16 +++++----- e2e-tests/visual.spec.ts | 35 +++++++++++++++------- examples/plugins/admin/plugin.ts | 2 +- examples/plugins/scheduling/plugin.ts | 2 +- public/css/styles.css | 18 +++++++++-- release-tooling/contract-version.test.ts | 2 +- release-tooling/dockerhub-overview.md.tmpl | 2 +- src/plugin-host/plugin.ts | 2 +- src/ui/shell.test.ts | 10 +++++++ views/index.ejs | 2 +- views/partials/shell.ejs | 7 +++-- 13 files changed, 117 insertions(+), 39 deletions(-) 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, /