Scroll the document by default; a bounded region is a page's own opt-in
This commit is contained in:
@@ -278,6 +278,16 @@ 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 scroll region is opt-in.** `.app` is `min-height: 100dvh`, not
|
||||||
|
a `100dvh` box with `overflow: hidden`: the sidebar is `position: sticky` at full height and the
|
||||||
|
topbar sticks too, so both stay put while the content column flows with the page. A page that wants
|
||||||
|
a region scrolling inside it — a board of full-height columns, a table whose header stays put —
|
||||||
|
gives that region a height and scrolls within it; `.table-wrap` is `overflow-x: auto` and nothing
|
||||||
|
more until a page does. The inverse default clipped the first long page that did not know the rule
|
||||||
|
(2026-09-06), silently, in every engine: nothing in a test or a console says a page is unreachable
|
||||||
|
below the fold. Document scrolling is also what keeps find-in-page, anchor links, keyboard paging,
|
||||||
|
print and reader mode working. The `visual.spec.ts` wheel test holds this; scrollIntoView would not,
|
||||||
|
since a script can scroll an overflow-hidden box and a reader cannot.
|
||||||
- **`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
|
||||||
|
|||||||
@@ -933,6 +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
|
||||||
|
below the fold without adding a scroll region. A region that should scroll *inside* the page — a
|
||||||
|
board of full-height columns, a table whose header stays put — sets its own height and `overflow`.
|
||||||
|
|
||||||
## Building blocks
|
## Building blocks
|
||||||
|
|
||||||
Plainpages is a **component library, not a page generator** — reusable EJS partials + TS helpers,
|
Plainpages is a **component library, not a page generator** — reusable EJS partials + TS helpers,
|
||||||
|
|||||||
@@ -28,6 +28,20 @@ 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() }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The shell must never clip a page: a body that does not scroll itself has to reach the reader
|
||||||
|
// through the document. A key press, not scrollIntoView — a script can scroll an overflow-hidden
|
||||||
|
// box, a reader cannot. End rather than the wheel: Firefox's synthetic wheel never reaches the
|
||||||
|
// document.
|
||||||
|
test("a page taller than the window scrolls, so its last control can be reached", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1280, height: 240 });
|
||||||
|
await page.goto("/dashboard");
|
||||||
|
|
||||||
|
const last = page.locator(".form-actions .btn").last();
|
||||||
|
await expect(last).not.toBeInViewport();
|
||||||
|
await page.keyboard.press("End");
|
||||||
|
await expect(last).toBeInViewport();
|
||||||
|
});
|
||||||
|
|
||||||
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();
|
||||||
|
|||||||
+10
-7
@@ -111,7 +111,7 @@ html:has(#theme-light:checked) {
|
|||||||
|
|
||||||
/* ---------- 2. RESET ---------------------------------------- */
|
/* ---------- 2. RESET ---------------------------------------- */
|
||||||
*, *::before, *::after { box-sizing: border-box; }
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
html, body { height: 100%; }
|
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; }
|
||||||
@@ -148,19 +148,20 @@ 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
|
||||||
|
bringing a scroll region of its own (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);
|
||||||
height: 100dvh;
|
min-height: 100dvh;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 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;
|
||||||
min-height: 0;
|
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
@@ -331,12 +332,13 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
.content {
|
.content {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
min-width: 0; min-height: 0;
|
min-width: 0;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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: 48px;
|
||||||
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);
|
||||||
@@ -559,7 +561,8 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
.pill-clear:hover { text-decoration: underline; }
|
.pill-clear:hover { text-decoration: underline; }
|
||||||
|
|
||||||
/* ---------- 9. TABLE --------------------------------------- */
|
/* ---------- 9. TABLE --------------------------------------- */
|
||||||
.table-wrap { flex: 1 1 auto; min-height: 0; overflow: auto; }
|
/* A bounded region is opt-in: give this a height and the header below stays put inside it. */
|
||||||
|
.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;
|
||||||
@@ -723,4 +726,4 @@ 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; }
|
||||||
/* 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; overflow-y: 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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user