From 4ae93267417afdf8bc4f82f77ac6645bd79121b7 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 9 Sep 2026 14:11:10 +0200 Subject: [PATCH] Split the fill seam where ownership splits: the shell bounds, the page fills --- AGENTS.md | 19 +++++++------ CHANGELOG.md | 20 ++++++++----- README.md | 13 ++++++--- e2e-tests/full-flow.spec.ts | 28 +++++++++++++++++++ e2e-tests/visual.spec.ts | 9 ++++++ .../plugins/scheduling/public/scheduling.css | 9 ++++++ examples/plugins/scheduling/views/shifts.ejs | 3 +- public/css/styles.css | 8 ++++-- src/ui/shell.test.ts | 5 ++-- views/partials/data-table.ejs | 6 +++- 10 files changed, 95 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f194e1f..9433a5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -282,16 +282,19 @@ Revisit only if the stated reason stops holding. `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 +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), **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. + 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 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 + — 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 `fills: true` for exactly this, since its + wrapper is host markup a page cannot put a class on. 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fb7cc7..121cf3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,22 +19,28 @@ Now the shell is `min-height: 100dvh` and the document scrolls. The sidebar is ` 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 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. `data-table`'s sticky `thead` needs it, since a header sticks -only to a scrollport that moves. +page, and a region inside it scrolls. -The height is the shell's to give: a page computing it would have to know the topbar's own height. +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 `fills: true`, which puts the class on its own wrapper. ### 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. +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, and `fill`'s bounded box clips it. +4. A table whose header must stay put passes `fills: true` to `data-table` on such a page. + `examples/plugins/scheduling` shows the whole chain on its shifts list. ## 0.3.0 diff --git a/README.md b/README.md index ff0ff42..fae5505 100644 --- a/README.md +++ b/README.md @@ -934,10 +934,15 @@ 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 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. +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 `fills: 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. ## Building blocks diff --git a/e2e-tests/full-flow.spec.ts b/e2e-tests/full-flow.spec.ts index b043e7d..0190dbd 100644 --- a/e2e-tests/full-flow.spec.ts +++ b/e2e-tests/full-flow.spec.ts @@ -201,6 +201,34 @@ 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 () => { + await page.setViewportSize({ width: 1280, height: 300 }); + await page.goto("/scheduling/shifts"); + + const bounded = await page.evaluate(() => { + const wrap = document.querySelector(".table-wrap"); + if (!(wrap instanceof HTMLElement)) return null; + return { + documentScrolls: document.documentElement.scrollHeight > window.innerHeight, + regionScrolls: wrap.scrollHeight > wrap.clientHeight, + }; + }); + expect(bounded, ".table-wrap must render").not.toBeNull(); + // Both halves: the shell bounds the column, and the page's chain hands that height to the table. + // Miss a wrapper and the region grows instead, which is what the bounded box would then clip. + expect(bounded?.documentScrolls, "the document must not scroll on a filled page").toBe(false); + expect(bounded?.regionScrolls, "the table must be the thing that scrolls").toBe(true); + + const headTop = async () => (await page.locator("thead th").first().boundingBox())?.y ?? -1; + 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); + }); + 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 403bf01..01d0130 100644 --- a/e2e-tests/visual.spec.ts +++ b/e2e-tests/visual.spec.ts @@ -51,6 +51,15 @@ for (const [name, path, tail] of [ // 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: the page must not slide out from under the scrim. + 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(); + const before = await page.evaluate(() => window.scrollY); + await page.keyboard.press("Home"); + expect(await page.evaluate(() => window.scrollY), "the page is locked while the nav is open").toBe(before); + } }); } } diff --git a/examples/plugins/scheduling/public/scheduling.css b/examples/plugins/scheduling/public/scheduling.css index d284249..e1c3f71 100644 --- a/examples/plugins/scheduling/public/scheduling.css +++ b/examples/plugins/scheduling/public/scheduling.css @@ -4,3 +4,12 @@ .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 e426f3b..34dcdc3 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); + const tableHtml = include("partials/data-table", { ...table, fills: true }); const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : ""; const actions = canWrite ? '' + t("scheduling.shifts.new") + '' @@ -16,6 +16,7 @@ <%- include("partials/shell", { actions, body: '
' + alertHtml + filtersHtml + '

' + count + '

' + tableHtml + '
', + fill: true, // a long list scrolls under its own header rather than taking the page with it brand: chrome.brand, breadcrumbs, csrfToken: chrome.csrfToken, diff --git a/public/css/styles.css b/public/css/styles.css index c59a837..f509a26 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -733,9 +733,13 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); } /* `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. */ + know the topbar's own. What fills that height is the page's to say — `.scroll-region` below. */ .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; } + +/* Marks the one element that takes the leftover height and scrolls. Every ancestor between it and + `.content` has to be a flex column carrying this same pair, which is the page's own business — + `min-height: 0` is the half everyone forgets, since `auto` floors a flex item at its content. */ +.scroll-region { 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/src/ui/shell.test.ts b/src/ui/shell.test.ts index 5768bdf..e79de51 100644 --- a/src/ui/shell.test.ts +++ b/src/ui/shell.test.ts @@ -100,9 +100,10 @@ test("app shell can disable the menu: no sidebar, focused single-column layout", assert.match(bare, /
x<\/section>/); }); -test("app shell: fill:true bounds the viewport, for a page whose own region scrolls", async () => { +test("app shell: fill is opt-in and composes with menu:false; what the class does is pinned in e2e", 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). + // without knowing the topbar's own (AGENTS.md → UI). Its effect depends on the page's own flex + // chain, so full-flow holds that; this holds only that nothing opts a page in by accident. const filled = await render({ fill: true, title: "Board", body: "
x
", nav: "" }); assert.match(filled, /
/); const bothOff = await render({ fill: true, menu: false, title: "Board", body: "
x
", nav: "" }); diff --git a/views/partials/data-table.ejs b/views/partials/data-table.ejs index 3710bd1..317215d 100644 --- a/views/partials/data-table.ejs +++ b/views/partials/data-table.ejs @@ -5,6 +5,9 @@ caption?, selectable?, actions? sr-only caption; toggle the check / kebab columns actionsId? id stem for the row-action menus (default `row-actions`); name it when two tables share a page + fills? take the leftover height and scroll, so the header stays + put — needs the shell's `fill: true` and a flex chain + above (README → the app shell) columns: { label, sortable?, sort?: "asc"|"desc", href?, className? }[] rows: { name?, cells: Cell[], actions?: Action[] }[] Cell ∈ string | { text, className? } | { user:{name,initials} } | { rowHeader:{text,href?} } | { badge:{tone,label} } | { html, className? } @@ -20,8 +23,9 @@ const rows = locals.rows || []; const emptyText = locals.emptyText || t("table.empty"); // shown when a table that has columns has no rows const actionsId = locals.actionsId || "row-actions"; + const fills = locals.fills === true; -%> -
+
"> <% if (caption) { -%>
<%= caption %>