diff --git a/AGENTS.md b/AGENTS.md index fd9acb6..6919f70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b97664..4e4f76a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 43dfe3a..1e63a1a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/e2e-tests/full-flow.spec.ts b/e2e-tests/full-flow.spec.ts index 302e2b5..b043e7d 100644 --- a/e2e-tests/full-flow.spec.ts +++ b/e2e-tests/full-flow.spec.ts @@ -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"); diff --git a/e2e-tests/visual.spec.ts b/e2e-tests/visual.spec.ts index 2cd9cf9..c30c61d 100644 --- a/e2e-tests/visual.spec.ts +++ b/e2e-tests/visual.spec.ts @@ -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); - } - } }); } } diff --git a/examples/plugins/scheduling/public/scheduling.css b/examples/plugins/scheduling/public/scheduling.css index e1c3f71..d284249 100644 --- a/examples/plugins/scheduling/public/scheduling.css +++ b/examples/plugins/scheduling/public/scheduling.css @@ -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; -} diff --git a/examples/plugins/scheduling/views/shifts.ejs b/examples/plugins/scheduling/views/shifts.ejs index 66e9625..e426f3b 100644 --- a/examples/plugins/scheduling/views/shifts.ejs +++ b/examples/plugins/scheduling/views/shifts.ejs @@ -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 ? '' + t("scheduling.shifts.new") + '' @@ -16,7 +16,6 @@ <%- include("partials/shell", { actions, body: '
' + count + '
' + tableHtml + '| <\/th>/); assert.match(html, / | <\/td>/); diff --git a/src/ui/shell.test.ts b/src/ui/shell.test.ts index 2054475..b062e73 100644 --- a/src/ui/shell.test.ts +++ b/src/ui/shell.test.ts @@ -95,20 +95,10 @@ test("app shell can disable the menu: no sidebar, focused single-column layout", assert.doesNotMatch(bare, / |
|---|