Scroll the document, and let the chrome scroll with it #110
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: "<h1>Hello from my plugin</h1>" }) },
|
||||
@@ -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<typeof postgres>;
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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();
|
||||
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(last).toBeInViewport();
|
||||
// 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");
|
||||
|
||||
@@ -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],
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+15
-3
@@ -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; }
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: "<h1>Hello from my plugin</h1>" }) },
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -95,10 +95,20 @@ 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:true bounds the viewport, for a page whose own region scrolls", async () => {
|
||||
// The opt-out from document scrolling is the shell's to give — a page cannot derive the height
|
||||
// without knowing the topbar's own (AGENTS.md → UI).
|
||||
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
-1
@@ -14,7 +14,7 @@
|
||||
<p>${t("dashboard.starter.intro")}</p>
|
||||
<p>${t("dashboard.starter.replace")}</p>
|
||||
<pre class="code-block"><code>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: { /* … */ } }),
|
||||
});</code></pre>
|
||||
|
||||
@@ -9,13 +9,16 @@
|
||||
(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. `t`, `locale`, `dir` and `localeSwitch`
|
||||
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`
|
||||
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 || [];
|
||||
@@ -41,7 +44,7 @@
|
||||
<input type="checkbox" id="nav-toggle" aria-hidden="true" tabindex="-1" />
|
||||
<% } %>
|
||||
|
||||
<div class="app<%= menu ? "" : " app-bare" %>">
|
||||
<div class="app<%= menu ? "" : " app-bare" %><%= fill ? " app-fill" : "" %>">
|
||||
<% if (menu) { %>
|
||||
<aside class="sidebar" aria-label="<%= t("shell.sidebar") %>">
|
||||
<div class="brand">
|
||||
|
||||
Reference in New Issue
Block a user