One home per fact: cut the copied rationale, and give the seam test room to mean something
CI / full-gate (push) Successful in 2m57s
CI / full-gate (push) Successful in 2m57s
This commit is contained in:
@@ -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
|
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
|
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
|
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
|
silently clips one nested any deeper. `data-table` takes `scrollRegion: true` for
|
||||||
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
|
exactly this, since its wrapper is host markup a page cannot put the class on itself.
|
||||||
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
|
- **`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
|
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
|
to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an
|
||||||
|
|||||||
+4
-2
@@ -34,11 +34,13 @@ clip one nested any deeper. `data-table` takes `scrollRegion: true`, which puts
|
|||||||
### Upgrading a plugin
|
### Upgrading a plugin
|
||||||
|
|
||||||
1. Set `apiVersion: "0.4.0"`.
|
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
|
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
|
`.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
|
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.
|
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.
|
`examples/plugins/scheduling` shows the whole chain on its shifts list.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// 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 () => {
|
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");
|
await page.goto("/scheduling/shifts");
|
||||||
|
|
||||||
const bounded = await page.evaluate(() => {
|
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
|
// 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.
|
// 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?.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();
|
const before = await headTop();
|
||||||
await page.locator(".table-wrap").evaluate((el) => el.scrollTo(0, el.scrollHeight));
|
await page.locator(".table-wrap").evaluate((el) => el.scrollTo(0, el.scrollHeight));
|
||||||
await expect(page.locator("tbody tr").last()).toBeInViewport();
|
await expect(page.locator("tbody tr").last()).toBeInViewport();
|
||||||
expect(await headTop(), "the header sticks to a scrollport that moves").toBe(before);
|
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 () => {
|
test("plugin settings: the screen names the variable that sets each declared key", async () => {
|
||||||
|
|||||||
@@ -52,13 +52,14 @@ for (const [name, path, tail] of [
|
|||||||
await expect(page.locator(".topbar")).toBeInViewport();
|
await expect(page.locator(".topbar")).toBeInViewport();
|
||||||
if (width > 860) await expect(page.locator(".brand-name")).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) {
|
if (width <= 860) {
|
||||||
await page.locator(".hamburger").click(); // the label is the control; the checkbox takes no pointer
|
await page.locator(".hamburger").click(); // the label is the control; the checkbox takes no pointer
|
||||||
await expect(page.locator("#nav-toggle")).toBeChecked();
|
await expect(page.locator("#nav-toggle")).toBeChecked();
|
||||||
const before = await page.evaluate(() => window.scrollY);
|
const before = await page.evaluate(() => window.scrollY);
|
||||||
await page.keyboard.press("Home");
|
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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-11
@@ -112,7 +112,6 @@ html:has(#theme-light:checked) {
|
|||||||
|
|
||||||
/* ---------- 2. RESET ---------------------------------------- */
|
/* ---------- 2. RESET ---------------------------------------- */
|
||||||
*, *::before, *::after { box-sizing: border-box; }
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
html, body { min-height: 100%; }
|
|
||||||
body { margin: 0; background: var(--bg); color: var(--text);
|
body { margin: 0; background: var(--bg); color: var(--text);
|
||||||
-webkit-font-smoothing: antialiased; }
|
-webkit-font-smoothing: antialiased; }
|
||||||
button { font: inherit; color: inherit; }
|
button { font: inherit; color: inherit; }
|
||||||
@@ -149,8 +148,7 @@ summary { list-style: none; cursor: pointer; }
|
|||||||
.ico-sm { width: 14px; height: 14px; }
|
.ico-sm { width: 14px; height: 14px; }
|
||||||
|
|
||||||
/* ---------- 3. APP GRID ------------------------------------- */
|
/* ---------- 3. APP GRID ------------------------------------- */
|
||||||
/* Not a viewport-height box: the document scrolls, so a page is reachable below the fold without
|
/* The document scrolls; `fill` opts out — see .app-fill (AGENTS.md → UI). */
|
||||||
bringing a scroll region of its own. `fill` opts out — see .app-fill (AGENTS.md → UI). */
|
|
||||||
.app {
|
.app {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--nav-w) minmax(0, 1fr);
|
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);
|
background: rgba(0,0,0,.42);
|
||||||
}
|
}
|
||||||
.search { min-width: 150px; flex: 1 1 auto; }
|
.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; }
|
body:has(#nav-toggle:checked) { overflow: hidden; }
|
||||||
}
|
}
|
||||||
@media (max-width: 560px) {
|
@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 { grid-template-columns: minmax(0, 1fr); }
|
||||||
.app-bare .content { grid-column: 1; }
|
.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
|
/* The shell's half of `fill: true` (AGENTS.md → UI). `auto`, not `hidden`: a page that misses a
|
||||||
document. For a page whose whole point is a bounded frame — a board of full-height columns, a table
|
wrapper in its own chain then scrolls here instead of losing the overflow. */
|
||||||
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. */
|
|
||||||
.app-fill { height: 100dvh; overflow: auto; }
|
.app-fill { height: 100dvh; overflow: auto; }
|
||||||
.app-fill .content { min-height: 0; }
|
.app-fill .content { min-height: 0; }
|
||||||
|
|
||||||
/* Marks the one element that takes the leftover height and scrolls. Every ancestor between it and
|
/* The page's half: `min-height: 0` is the one everyone forgets, since `auto` floors a flex item at
|
||||||
`.content` has to be a flex column carrying this same pair, which is the page's own business —
|
its content. */
|
||||||
`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; }
|
.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. */
|
/* 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; }
|
.shell-auth { flex: 1 1 auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; }
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ test("data-table renders sortable headers, row-select, typed cells, badges and k
|
|||||||
const html = flat(await render(config));
|
const html = flat(await render(config));
|
||||||
|
|
||||||
assert.match(html, /<div class="table-wrap"><table class="table"><caption class="sr-only">People in the directory<\/caption>/);
|
assert.match(html, /<div class="table-wrap"><table class="table"><caption class="sr-only">People in the directory<\/caption>/);
|
||||||
|
// Opt-in, and only then: the class is what a filled page hands its leftover height to.
|
||||||
|
assert.match(flat(await render({ ...config, scrollRegion: true })), /<div class="table-wrap scroll-region">/);
|
||||||
|
|
||||||
// Row-select: header select-all + per-row checkbox with a descriptive label.
|
// Row-select: header select-all + per-row checkbox with a descriptive label.
|
||||||
assert.match(html, /<th class="col-check" scope="col"><input type="checkbox" aria-label="Select all rows"><\/th>/);
|
assert.match(html, /<th class="col-check" scope="col"><input type="checkbox" aria-label="Select all rows"><\/th>/);
|
||||||
|
|||||||
@@ -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 () => {
|
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
|
// What the class does depends on the page's own flex chain (AGENTS.md → UI), so full-flow holds
|
||||||
// without knowing the topbar's own (AGENTS.md → UI). Its effect depends on the page's own flex
|
// that; this holds only that nothing opts a page in by accident.
|
||||||
// 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: "<div>x</div>", nav: "" });
|
const filled = await render({ fill: true, title: "Board", body: "<div>x</div>", nav: "" });
|
||||||
assert.match(filled, /<div class="app app-fill">/);
|
assert.match(filled, /<div class="app app-fill">/);
|
||||||
const bothOff = await render({ fill: true, menu: false, title: "Board", body: "<div>x</div>", nav: "" });
|
const bothOff = await render({ fill: true, menu: false, title: "Board", body: "<div>x</div>", nav: "" });
|
||||||
|
|||||||
Reference in New Issue
Block a user