A page is a document: drop the sticky chrome and the bounded-frame opt-out
CI / full-gate (push) Successful in 2m49s

This commit is contained in:
2026-09-09 15:26:45 +02:00
parent 33065afd18
commit f959404c6f
12 changed files with 39 additions and 170 deletions
+14 -17
View File
@@ -36,6 +36,12 @@ branch, create a PR and merge it when the CI/CD turns green.
## Project priorities (do not erode) ## Project priorities (do not erode)
1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable. 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`, 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. `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 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 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 profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep
the two in step. the two in step.
- **The document scrolls; a bounded frame is `fill: true` on the shell.** `.app` is - **Nothing bounds the viewport, so there is one scroller: the document.** `.app` is
`min-height: 100dvh`, not a `100dvh` box with `overflow: hidden`: the sidebar is `position: sticky` `min-height: 100dvh`, never a `100dvh` box with `overflow: hidden`. It was the latter once, and a
at full height and the topbar sticks with it, so the nav stays reachable — on a narrow screen the page taller than the window lost everything past it — silently, in every engine, because nothing in
hamburger is the only way into it, and with no script a long page would otherwise strand the reader. a test or a console says content is unreachable below the fold. Only the document scroller gets
Two things only the document scroller gets: **keyboard paging** unconditionally (Space, PgDn and **keyboard paging** unconditionally and **scroll restoration** on back/forward; a bounded region
End reach a bounded region only once focus is inside it, which without script needs a focusable gets keys only once focus is inside it, which without script needs a focusable descendant.
descendant) and **scroll restoration** on back/forward. The inverse default clipped a page silently A plugin that wants a full-height pane owns that in its own stylesheet — the shell offers no opt-out
in every engine — nothing in a test or a console says content is unreachable below the fold. and no `.table thead` stickiness, per priority 1.
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.
- **`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
+13 -24
View File
@@ -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 **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. 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 `.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 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. 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 Now the shell is `min-height: 100dvh` and nothing bounds the viewport. The sidebar and topbar scroll
full height and the topbar sticks with it, so both stay put as the page flows. Keyboard paging and with the page, and keyboard paging, back/forward scroll restoration and find-in-page work without a
back/forward scroll restoration work without a page doing anything. page doing anything.
### A bounded frame is `fill: true`, and the page says what fills it 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
A page whose whole point is a frame — a board of full-height columns, a table whose header must stay shell offers no opt-out, per the simplicity priority in `AGENTS.md`.
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.
### 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. A 2. A page that scrolled the whole window needs no change — it now scrolls the document.
`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 (`flex: 1 1 auto; min-height: 0` with its
3. A page holding a region that filled the content column passes `fill: true` to the shell, puts own `overflow`) no longer gets a bounded column to fill, so that region grows and the page scrolls.
`.scroll-region` on that region, and makes every wrapper between it and the content slot a flex Either let it, or give the region its own height in the plugin's stylesheet.
column (`display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0`). Miss a wrapper and 4. A `data-table` keeps working; its header no longer stays put while the rows scroll.
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.
## 0.3.0 ## 0.3.0
+4 -10
View File
@@ -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 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`. 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 **The document scrolls, and the chrome scrolls with it.** Nothing bounds the viewport, so a page is
below the fold without adding a scroll region of its own. 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
A page that is a bounded frame instead — a board of full-height columns, a table whose header must pane owns that in its own stylesheet.
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.
## Building blocks ## Building blocks
-38
View File
@@ -201,44 +201,6 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page.locator("table")).not.toContainText("Morning — Front desk"); 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 () => { test("plugin settings: the screen names the variable that sets each declared key", async () => {
await page.goto("/admin/plugin-settings"); await page.goto("/admin/plugin-settings");
await expect(page.locator("h1")).toHaveText("Plugin settings"); await expect(page.locator("h1")).toHaveText("Plugin settings");
+1 -22
View File
@@ -35,7 +35,7 @@ for (const [name, path, tail] of [
["the public landing", "/", ".landing-actions .btn"], ["the public landing", "/", ".landing-actions .btn"],
] as const) { ] as const) {
for (const width of [1280, 390]) { 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.setViewportSize({ width, height: 200 });
await page.goto(path); await page.goto(path);
@@ -44,27 +44,6 @@ for (const [name, path, tail] of [
await page.keyboard.press("End"); await page.keyboard.press("End");
// Whole, not merely touched: a control half under the fold is not reachable either. // Whole, not merely touched: a control half under the fold is not reachable either.
await expect(page.locator(tail).last()).toBeInViewport({ ratio: 1 }); 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);
}
}
}); });
} }
} }
@@ -4,12 +4,3 @@
.scheduling-page .table-wrap { .scheduling-page .table-wrap {
margin-top: var(--space-3, 0.75rem); 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;
}
+1 -2
View File
@@ -7,7 +7,7 @@
%><% %><%
const navHtml = include("partials/nav-tree", { nodes: chrome.nav }); const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
const filtersHtml = include("partials/filter-bar", filterBar); 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 alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : "";
const actions = canWrite const actions = canWrite
? '<a class="btn btn-primary" href="' + localeHref(newHref) + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("scheduling.shifts.new") + '</a>' ? '<a class="btn btn-primary" href="' + localeHref(newHref) + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("scheduling.shifts.new") + '</a>'
@@ -16,7 +16,6 @@
<%- include("partials/shell", { <%- include("partials/shell", {
actions, actions,
body: '<div class="scheduling-page">' + alertHtml + filtersHtml + '<p class="shift-count">' + count + '</p>' + tableHtml + '</div>', body: '<div class="scheduling-page">' + alertHtml + filtersHtml + '<p class="shift-count">' + count + '</p>' + tableHtml + '</div>',
fill: true, // a long list scrolls under its own header rather than taking the page with it
brand: chrome.brand, brand: chrome.brand,
breadcrumbs, breadcrumbs,
csrfToken: chrome.csrfToken, csrfToken: chrome.csrfToken,
+3 -25
View File
@@ -57,7 +57,6 @@
/* layout + density (compact) */ /* layout + density (compact) */
--radius: 5px; --radius: 5px;
--nav-w: 264px; --nav-w: 264px;
--topbar-h: 48px;
--row-h: 34px; --row-h: 34px;
--pad-x: 12px; --pad-x: 12px;
--fz: 13px; --fz: 13px;
@@ -148,7 +147,6 @@ summary { list-style: none; cursor: pointer; }
.ico-sm { width: 14px; height: 14px; } .ico-sm { width: 14px; height: 14px; }
/* ---------- 3. APP GRID ------------------------------------- */ /* ---------- 3. APP GRID ------------------------------------- */
/* The document scrolls; `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);
@@ -158,7 +156,6 @@ summary { list-style: none; cursor: pointer; }
/* ---------- 4. SIDEBAR -------------------------------------- */ /* ---------- 4. SIDEBAR -------------------------------------- */
.sidebar { .sidebar {
grid-column: 1; grid-column: 1;
position: sticky; top: 0; height: 100dvh; align-self: start;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--surface); background: var(--surface);
@@ -166,7 +163,7 @@ summary { list-style: none; cursor: pointer; }
} }
.brand { .brand {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
height: var(--topbar-h); padding: 0 14px; flex: 0 0 auto; height: 48px; padding: 0 14px; flex: 0 0 auto;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.brand-mark { .brand-mark {
@@ -337,14 +334,12 @@ span.nav-self { cursor: default; } /* static / non-clickable */
/* topbar (page title + hamburger on mobile) */ /* topbar (page title + hamburger on mobile) */
.topbar { .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; display: flex; align-items: center; gap: 12px;
padding: 0 16px; border-bottom: 1px solid var(--border); padding: 0 16px; border-bottom: 1px solid var(--border);
background: var(--surface); background: var(--surface);
} }
.hamburger { display: none; } /* shown only on narrow */ .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-title { margin: 0; font-weight: 600; letter-spacing: -.01em; font-size: 14px; }
.page-sub { color: var(--text-faint); font-size: var(--fz-sm); } .page-sub { color: var(--text-faint); font-size: var(--fz-sm); }
.topbar-spacer { flex: 1 1 auto; } .topbar-spacer { flex: 1 1 auto; }
@@ -562,13 +557,12 @@ span.nav-self { cursor: default; } /* static / non-clickable */
/* ---------- 9. TABLE --------------------------------------- */ /* ---------- 9. TABLE --------------------------------------- */
/* A bounded region is opt-in: give this a height and the header below stays put inside it. */ /* A bounded region is opt-in: give this a height and the header below stays put inside it. */
.table-wrap { overflow: auto; } .table-wrap { overflow-x: auto; }
table.table { table.table {
width: 100%; border-collapse: separate; border-spacing: 0; width: 100%; border-collapse: separate; border-spacing: 0;
font-size: var(--fz); font-variant-numeric: tabular-nums; font-size: var(--fz); font-variant-numeric: tabular-nums;
} }
.table thead th { .table thead th {
position: sticky; top: 0; z-index: 10;
background: var(--surface-3); background: var(--surface-3);
border-bottom: 1px solid var(--border-2); border-bottom: 1px solid var(--border-2);
color: var(--text-muted); font-weight: 600; font-size: var(--fz-xs); color: var(--text-muted); font-weight: 600; font-size: var(--fz-xs);
@@ -693,8 +687,6 @@ 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: a reader scrolling would move what the scrim covers. */
body:has(#nav-toggle:checked) { overflow: hidden; }
} }
@media (max-width: 560px) { @media (max-width: 560px) {
.crumbs, .page-sub { display: none; } .crumbs, .page-sub { display: none; }
@@ -728,19 +720,5 @@ 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; }
/* 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; }
/* 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. */ /* 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; }
-3
View File
@@ -42,9 +42,6 @@ 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>/);
assert.match(html, /<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Mara Delgado"><\/td>/); assert.match(html, /<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Mara Delgado"><\/td>/);
-10
View File
@@ -95,20 +95,10 @@ test("app shell can disable the menu: no sidebar, focused single-column layout",
assert.doesNotMatch(bare, /<aside class="sidebar"/); // sidebar dropped assert.doesNotMatch(bare, /<aside class="sidebar"/); // sidebar dropped
assert.doesNotMatch(bare, /class="hamburger"/); // and its mobile toggle assert.doesNotMatch(bare, /class="hamburger"/); // and its mobile toggle
assert.match(bare, /<div class="app app-bare">/); // single-column variant 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, /<main class="content" id="main-content"/); // content still renders
assert.match(bare, /<section id="b">x<\/section>/); assert.match(bare, /<section id="b">x<\/section>/);
}); });
test("app shell: fill is opt-in and composes with menu:false; what the class does is pinned in e2e", async () => {
// 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: "<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 () => { 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. // 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>" }); const html = await render({ title: "", docTitle: "Sign in", brand: { name: "Acme" }, body: "<h1>Sign in</h1>" });
+1 -5
View File
@@ -5,9 +5,6 @@
caption?, selectable?, actions? sr-only caption; toggle the check / kebab columns caption?, selectable?, actions? sr-only caption; toggle the check / kebab columns
actionsId? id stem for the row-action menus (default `row-actions`); actionsId? id stem for the row-action menus (default `row-actions`);
name it when two tables share a page name it when two tables share a page
scrollRegion? take the leftover height and scroll, so the header stays
put — needs the shell's `fill: true` and a flex chain
above (README → The menu system)
columns: { label, sortable?, sort?: "asc"|"desc", href?, className? }[] columns: { label, sortable?, sort?: "asc"|"desc", href?, className? }[]
rows: { name?, cells: Cell[], actions?: Action[] }[] rows: { name?, cells: Cell[], actions?: Action[] }[]
Cell ∈ string | { text, className? } | { user:{name,initials} } | { rowHeader:{text,href?} } | { badge:{tone,label} } | { html, className? } Cell ∈ string | { text, className? } | { user:{name,initials} } | { rowHeader:{text,href?} } | { badge:{tone,label} } | { html, className? }
@@ -23,9 +20,8 @@
const rows = locals.rows || []; const rows = locals.rows || [];
const emptyText = locals.emptyText || t("table.empty"); // shown when a table that has columns has no 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 actionsId = locals.actionsId || "row-actions";
const scrollRegion = locals.scrollRegion === true;
-%> -%>
<div class="table-wrap<%= scrollRegion ? " scroll-region" : "" %>"> <div class="table-wrap">
<table class="table"> <table class="table">
<% if (caption) { -%> <% if (caption) { -%>
<caption class="sr-only"><%= caption %></caption> <caption class="sr-only"><%= caption %></caption>
+2 -5
View File
@@ -9,16 +9,13 @@
(the <title> tag; defaults to title or the brand), `brand` ({ name, logo?, sub? }), `theme` (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), (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 `signInHref` (anonymous "Sign in" target; default /login). `menu` (default true) — set false to
drop the sidebar and render a focused single-column page. `fill` (default false) — set true when the drop the sidebar and render a focused single-column page. `t`, `locale`, `dir` and `localeSwitch`
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). come from the host with every render (README → Languages).
%><% %><%
const brand = locals.brand || { name: "Plainpages" }; const brand = locals.brand || { name: "Plainpages" };
const title = locals.title || ""; // topbar heading; empty ⇒ no topbar <h1> (the body owns it) const title = locals.title || ""; // topbar heading; empty ⇒ no topbar <h1> (the body owns it)
const docTitle = locals.docTitle || title || brand.name; const docTitle = locals.docTitle || title || brand.name;
const menu = locals.menu !== false; // sidebar shown by default; a page may opt out 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 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 user = locals.user || { name: "Guest", initials: "G", email: "" };
const breadcrumbs = locals.breadcrumbs || []; const breadcrumbs = locals.breadcrumbs || [];
@@ -44,7 +41,7 @@
<input type="checkbox" id="nav-toggle" aria-hidden="true" tabindex="-1" /> <input type="checkbox" id="nav-toggle" aria-hidden="true" tabindex="-1" />
<% } %> <% } %>
<div class="app<%= menu ? "" : " app-bare" %><%= fill ? " app-fill" : "" %>"> <div class="app<%= menu ? "" : " app-bare" %>">
<% if (menu) { %> <% if (menu) { %>
<aside class="sidebar" aria-label="<%= t("shell.sidebar") %>"> <aside class="sidebar" aria-label="<%= t("shell.sidebar") %>">
<div class="brand"> <div class="brand">