diff --git a/AGENTS.md b/AGENTS.md index ca83fd3..36e4390 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,15 @@ 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. Nothing may bound the + viewport to hold content still — no `height: 100dvh` frame, no `overflow: hidden` on `body`, no + `position: sticky` header. Each such box buys an app-like look with CSS the next reader has to + reverse-engineer, and is one more thing to undo before the content under it can be reached. + Overlays are not this: the skip link, the mobile off-canvas nav and its scrim sit *above* the + 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`, `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,6 +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 profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep the two in step. +- **One scroller, the document** (priority 1). `.app` is `min-height: 100dvh`. `.nav`'s + `overflow-y: auto` and `.side-footer`'s `flex: 0 0 auto` are not leftovers of a bounded frame: + they are what makes the off-canvas panel usable with a long tree. `#nav-toggle` is `position: fixed` — + a label click focuses it, and a browser scrolls a focused element into view. - **`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..c709ee7 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, 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 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. + +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. +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` 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 **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 52ca628..1e63a1a 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) => { @@ -933,6 +933,11 @@ 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, 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 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..ce75af7 100644 --- a/e2e-tests/visual.spec.ts +++ b/e2e-tests/visual.spec.ts @@ -28,6 +28,45 @@ test.beforeEach(async ({ context }) => { await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]); }); +// A key press, not scrollIntoView (a script can scroll a box no reader can) and not the wheel +// (Firefox's synthetic event never reaches 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`, async ({ page }) => { + await page.setViewportSize({ width, height: 200 }); + await page.goto(path); + + 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"); + await expect(page.locator(tail).last()).toBeInViewport({ ratio: 1 }); + }); + } +} + +// Green only while #nav-toggle is position: fixed — a label tap focuses it, and focus scrolls into view. +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 }) => { await page.goto("/dashboard"); await expect(page.locator(".sidebar")).toBeVisible(); 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 9ec5091..341a100 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -111,7 +111,6 @@ html:has(#theme-light:checked) { /* ---------- 2. RESET ---------------------------------------- */ *, *::before, *::after { box-sizing: border-box; } -html, body { height: 100%; } body { margin: 0; background: var(--bg); color: var(--text); -webkit-font-smoothing: antialiased; } button { font: inherit; color: inherit; } @@ -151,8 +150,7 @@ summary { list-style: none; cursor: pointer; } .app { display: grid; grid-template-columns: var(--nav-w) minmax(0, 1fr); - height: 100dvh; - overflow: hidden; + min-height: 100dvh; } /* ---------- 4. SIDEBAR -------------------------------------- */ @@ -160,7 +158,6 @@ summary { list-style: none; cursor: pointer; } grid-column: 1; display: flex; flex-direction: column; - min-height: 0; background: var(--surface); border-right: 1px solid var(--border); } @@ -331,7 +328,7 @@ 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); } @@ -559,13 +556,12 @@ 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; } +.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); @@ -697,7 +693,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); } } /* 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 */ .form-page { padding: 16px; display: flex; flex-direction: column; gap: 14px; max-width: 560px; } @@ -722,5 +718,6 @@ 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; } + /* 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; } 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/views/index.ejs b/views/index.ejs index 988cd16..29a9dee 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -14,7 +14,7 @@

${t("dashboard.starter.intro")}

${t("dashboard.starter.replace")}

export default definePlugin({
-  apiVersion: "0.3.0",
+  apiVersion: "0.4.0",
   // view names plugins/<id>/views/<view>.ejs, rendered in this same shell
   dashboard: (ctx) => ({ view: "dashboard", data: { /* … */ } }),
 });