Keep the reader in place when the drawer closes, and stop the CSS promising a header it deleted
CI / full-gate (push) Successful in 2m54s
CI / full-gate (push) Successful in 2m54s
This commit is contained in:
@@ -36,12 +36,15 @@ 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
|
**A page is a document**: it scrolls, and the chrome scrolls with it. Nothing may bound the
|
||||||
hold something still — a `100dvh` box, `position: sticky`/`fixed` chrome, `overflow: hidden` on
|
viewport to hold content still — no `height: 100dvh` frame, no `overflow: hidden` on `body`, no
|
||||||
`body` — buys an app-like look with CSS the next reader has to reverse-engineer, and every such
|
`position: sticky` header. Each such box buys an app-like look with CSS the next reader has to
|
||||||
box is one more thing to undo before the content under it can be reached. Sticky headers and
|
reverse-engineer, and is one more thing to undo before the content under it can be reached.
|
||||||
full-height panes do not earn that. The mobile off-canvas nav is the one exception, because an
|
Overlays are not this: the skip link, the mobile off-canvas nav and its scrim sit *above* the
|
||||||
overlay has no other spelling.
|
document rather than holding it still, and have no other spelling — the document keeps scrolling
|
||||||
|
behind the open nav, accepted rather than overlooked. Only the document scroller gets keyboard
|
||||||
|
paging unconditionally and back/forward scroll restoration, and a page a box clips fails silently:
|
||||||
|
nothing in a test or a console says content is unreachable below the fold.
|
||||||
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
|
||||||
@@ -284,14 +287,10 @@ 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.
|
||||||
- **Nothing bounds the viewport, so there is one scroller: the document.** `.app` is
|
- **One scroller, the document** (priority 1). `.app` is `min-height: 100dvh`. `.nav`'s
|
||||||
`min-height: 100dvh`, never a `100dvh` box with `overflow: hidden`. It was the latter once, and a
|
`overflow-y: auto` and `.side-footer`'s `flex: 0 0 auto` are not leftovers of a bounded frame:
|
||||||
page taller than the window lost everything past it — silently, in every engine, because nothing in
|
they are what makes the off-canvas panel usable with a long tree. `#nav-toggle` is `position: fixed`
|
||||||
a test or a console says content is unreachable below the fold. Only the document scroller gets
|
for the same reason — a label click focuses it, and a browser scrolls a focused element into view.
|
||||||
**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
|
- **`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
-1
@@ -30,8 +30,11 @@ shell offers no opt-out, per the simplicity priority in `AGENTS.md`.
|
|||||||
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 (`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.
|
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.
|
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.
|
4. A `data-table` no longer scrolls its rows in a bounded region: the page scrolls, and the header
|
||||||
|
scrolls with it.
|
||||||
|
|
||||||
|
The sidebar stretches the whole document, so on a long page its footer — theme, language, profile and
|
||||||
|
**Sign out** — sits at the end of that page rather than the bottom of the screen.
|
||||||
|
|
||||||
## 0.3.0
|
## 0.3.0
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ test.beforeEach(async ({ context }) => {
|
|||||||
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
|
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// A key press, not scrollIntoView — a script can scroll an overflow-hidden box, a reader cannot;
|
// A key press, not scrollIntoView (a script can scroll a box no reader can) and not the wheel
|
||||||
// and not the wheel, which Firefox's synthetic event never delivers to the document.
|
// (Firefox's synthetic event never reaches the document).
|
||||||
for (const [name, path, tail] of [
|
for (const [name, path, tail] of [
|
||||||
["the starter dashboard", "/dashboard", ".form-actions .btn"],
|
["the starter dashboard", "/dashboard", ".form-actions .btn"],
|
||||||
["the public landing", "/", ".landing-actions .btn"],
|
["the public landing", "/", ".landing-actions .btn"],
|
||||||
@@ -42,12 +42,34 @@ for (const [name, path, tail] of [
|
|||||||
const overflows = await page.evaluate(() => document.documentElement.scrollHeight > window.innerHeight);
|
const overflows = await page.evaluate(() => document.documentElement.scrollHeight > window.innerHeight);
|
||||||
expect(overflows, "the page must overflow, or it proves nothing").toBe(true);
|
expect(overflows, "the page must overflow, or it proves nothing").toBe(true);
|
||||||
await page.keyboard.press("End");
|
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 });
|
await expect(page.locator(tail).last()).toBeInViewport({ ratio: 1 });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The drawer is an overlay, so the document keeps scrolling behind it. Closing it must then leave
|
||||||
|
// the reader where they were: the toggle is focusable and its label is what a tap hits, and a
|
||||||
|
// browser scrolls a focused element into view — from the top of the document that meant jumping
|
||||||
|
// there. `#nav-toggle` is `position: fixed` so there is nothing to scroll to.
|
||||||
|
test("closing the mobile drawer leaves the reader where the scrim found them", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 200 });
|
||||||
|
await page.goto("/dashboard");
|
||||||
|
|
||||||
|
await page.locator(".hamburger").click();
|
||||||
|
await expect(page.locator("#nav-toggle")).toBeChecked();
|
||||||
|
// Scripted, because a key press with focus on the toggle does not scroll in every engine — and
|
||||||
|
// what is under test is closing the drawer, not how the reader got down the page.
|
||||||
|
await page.evaluate(() => window.scrollTo(0, 120));
|
||||||
|
const at = await page.evaluate(() => window.scrollY);
|
||||||
|
expect(at, "the page must have somewhere to scroll behind the scrim").toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// The exposed strip beside the 264px panel: the scrim spans the viewport, so its centre is under
|
||||||
|
// the drawer and a centre click lands on the panel instead.
|
||||||
|
await page.locator(".scrim").click({ position: { x: 340, y: 100 } });
|
||||||
|
await expect(page.locator("#nav-toggle")).not.toBeChecked();
|
||||||
|
expect(await page.evaluate(() => window.scrollY), "closing the drawer must not move the page").toBe(at);
|
||||||
|
});
|
||||||
|
|
||||||
test("captures the live pages for review", async ({ page }) => {
|
test("captures the live pages for review", async ({ page }) => {
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
await expect(page.locator(".sidebar")).toBeVisible();
|
await expect(page.locator(".sidebar")).toBeVisible();
|
||||||
|
|||||||
@@ -556,7 +556,6 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
.pill-clear:hover { text-decoration: underline; }
|
.pill-clear:hover { text-decoration: underline; }
|
||||||
|
|
||||||
/* ---------- 9. TABLE --------------------------------------- */
|
/* ---------- 9. TABLE --------------------------------------- */
|
||||||
/* A bounded region is opt-in: give this a height and the header below stays put inside it. */
|
|
||||||
.table-wrap { overflow-x: 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;
|
||||||
@@ -694,7 +693,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* the nav-toggle checkbox itself is visually hidden but focusable */
|
/* the nav-toggle checkbox itself is visually hidden but focusable */
|
||||||
#nav-toggle { position: absolute; opacity: 0; pointer-events: none; }
|
#nav-toggle { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; }
|
||||||
|
|
||||||
/* admin forms: create/edit user, account actions */
|
/* admin forms: create/edit user, account actions */
|
||||||
.form-page { padding: 16px; display: flex; flex-direction: column; gap: 14px; max-width: 560px; }
|
.form-page { padding: 16px; display: flex; flex-direction: column; gap: 14px; max-width: 560px; }
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ 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>/);
|
||||||
|
|
||||||
// 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>/);
|
||||||
|
|||||||
Reference in New Issue
Block a user