diff --git a/AGENTS.md b/AGENTS.md index 3d38b13..fd9acb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -293,10 +293,8 @@ Revisit only if the stated reason stops holding. 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. 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. + 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. - **`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 cdc0e45..fb5430f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,11 +34,13 @@ clip one nested any deeper. `data-table` takes `scrollRegion: true`, which puts ### 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. +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, and `fill`'s bounded box clips it. + the region grows instead of scrolling, so the frame scrolls in its place — a second scrollbar 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. diff --git a/e2e-tests/full-flow.spec.ts b/e2e-tests/full-flow.spec.ts index bb0974d..1b2637a 100644 --- a/e2e-tests/full-flow.spec.ts +++ b/e2e-tests/full-flow.spec.ts @@ -205,7 +205,9 @@ test.describe.serial("authenticated admin journey", () => { // 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 }); + // 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. + await page.setViewportSize({ width: 1280, height: 260 }); await page.goto("/scheduling/shifts"); const bounded = await page.evaluate(() => { @@ -221,13 +223,19 @@ test.describe.serial("authenticated admin journey", () => { // 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 table must be the thing that scrolls").toBe(true); + expect(bounded?.regionScrolls, "the fixture must overflow the region, or the rest proves nothing").toBe(true); - const headTop = async () => (await page.locator("thead th").first().boundingBox())?.y ?? -1; + 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. + await page.setViewportSize({ width: 1280, height: 800 }); }); test("plugin settings: the screen names the variable that sets each declared key", async () => { diff --git a/e2e-tests/visual.spec.ts b/e2e-tests/visual.spec.ts index 01d0130..91d5e00 100644 --- a/e2e-tests/visual.spec.ts +++ b/e2e-tests/visual.spec.ts @@ -52,13 +52,14 @@ for (const [name, path, tail] of [ 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. + // 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(); 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); + expect(await page.evaluate(() => window.scrollY), "a key press cannot scroll the page while the nav is open").toBe(before); } }); } diff --git a/public/css/styles.css b/public/css/styles.css index f517c6f..6461f71 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -112,7 +112,6 @@ html:has(#theme-light:checked) { /* ---------- 2. RESET ---------------------------------------- */ *, *::before, *::after { box-sizing: border-box; } -html, body { min-height: 100%; } body { margin: 0; background: var(--bg); color: var(--text); -webkit-font-smoothing: antialiased; } button { font: inherit; color: inherit; } @@ -149,8 +148,7 @@ summary { list-style: none; cursor: pointer; } .ico-sm { width: 14px; height: 14px; } /* ---------- 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. `fill` opts out — see .app-fill (AGENTS.md → UI). */ +/* The document scrolls; `fill` opts out — see .app-fill (AGENTS.md → UI). */ .app { display: grid; grid-template-columns: var(--nav-w) minmax(0, 1fr); @@ -695,7 +693,7 @@ 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. */ + /* The open nav is a fixed overlay: a reader scrolling would move what the scrim covers. */ body:has(#nav-toggle:checked) { overflow: hidden; } } @media (max-width: 560px) { @@ -730,16 +728,19 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); } .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. What fills that height is the page's to say — `.scroll-region` below. */ +/* The shell's half of `fill: true` (AGENTS.md → UI). `auto`, not `hidden`: a page that misses a + wrapper in its own chain then scrolls here instead of losing the overflow. */ .app-fill { height: 100dvh; overflow: auto; } .app-fill .content { min-height: 0; } -/* 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. */ +/* The page's half: `min-height: 0` is the one everyone forgets, since `auto` floors a flex item at + its content. */ .scroll-region { flex: 1 1 auto; min-height: 0; overflow-y: auto; } + +@media print { + /* A bounded frame prints as one sheet otherwise, losing everything the region scrolls past. */ + .app-fill { height: auto; overflow: visible; } + .scroll-region { overflow: visible; } +} /* 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/data-table.test.ts b/src/ui/data-table.test.ts index 839f530..cbf4893 100644 --- a/src/ui/data-table.test.ts +++ b/src/ui/data-table.test.ts @@ -42,6 +42,8 @@ test("data-table renders sortable headers, row-select, typed cells, badges and k const html = flat(await render(config)); assert.match(html, /
| <\/th>/);
diff --git a/src/ui/shell.test.ts b/src/ui/shell.test.ts
index e79de51..2054475 100644
--- a/src/ui/shell.test.ts
+++ b/src/ui/shell.test.ts
@@ -101,9 +101,8 @@ test("app shell can disable the menu: no sidebar, focused single-column layout",
});
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). 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.
+ // What the class does depends on the page's own flex chain (AGENTS.md → UI), 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: "" });
|
|---|