From 2ab482e7740296033f8af4831a5ca43e962b28f4 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 01:49:13 +0100 Subject: [PATCH 1/6] Keep the mobile nav reachable on engines without :has(), and fix the stylesheet header --- CHANGELOG.md | 6 ++++++ docs/reading-cost.md | 17 ++++++++++++++++ public/css/styles.css | 36 +++++++++++++++++++-------------- src/ui/stylesheet-floor.test.ts | 35 ++++++++++++++++++++++++++------ todo.md | 19 ----------------- 5 files changed, 73 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af019bf..c3376a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ at boot. Entries start at 0.3.0. ## Unreleased +### The mobile nav stays reachable on engines without `:has()` + +At 860px and narrower, an engine without `:has()` (Chromium below 105, such as webOS TV 22 and 23) +hid the sidebar off-canvas with no way to open it, so no page behind the nav could be reached. Now +the sidebar moves off-canvas, behind the hamburger, only where `:has()` works. Elsewhere it stays in +the flow above the content and the hamburger is hidden. ### The whole nav tree shows on engines without `:has()` On an engine without `:has()` (Chromium below 105, such as webOS TV 22 and 23, and Firefox below 121) diff --git a/docs/reading-cost.md b/docs/reading-cost.md index 6a5a028..9a758d9 100644 --- a/docs/reading-cost.md +++ b/docs/reading-cost.md @@ -30,3 +30,20 @@ Read most (opens and searches): - `AGENTS.md` 1 - `src/ui/stylesheet-floor.test.ts` 1 - `src/ui/theme-switch.test.ts` 1 + +## Reading cost, chunk 3bf0b1a97d77, finished 2026-10-31T00:48:14Z + +167985 new input tokens before the first commit. + +Read most (opens and searches): + +- `public/css/styles.css` 17 +- `todo.md` 7 +- `README.md` 6 +- `public/css/auth.css` 5 +- `AGENTS.md` 1 +- `CHANGELOG.md` 1 +- `examples/plugins/scheduling/public/scheduling.css` 1 +- `examples/plugins/scheduling/views/overview.ejs` 1 +- `examples/plugins/scheduling/views/shifts.ejs` 1 +- `src/http/app.test.ts` 1 diff --git a/public/css/styles.css b/public/css/styles.css index 77c4eea..5385632 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -2,8 +2,9 @@ App Shell Template — pure HTML/CSS, no JavaScript ------------------------------------------------------------- Architecture - - Design tokens via light-dark() so theme follows system by - default and can be forced Light / Dark via radio + :has(). + - Design tokens are two raw palettes picked by a theme block, so + the theme follows the system by default and can be forced + Light / Dark via radio + :has(). - Everything is a reusable "block": .nav-item, .filter, .chip, .badge, .btn, .menu, .table — combine freely. ============================================================= */ @@ -681,23 +682,28 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); } /* ---------- 11. RESPONSIVE / HAMBURGER --------------------- */ .scrim { display: none; } +/* Off-canvas only where :has() works: without it nothing can read #nav-toggle, so the sidebar + stays in the flow above the content and the hamburger stays hidden. */ @media (max-width: 860px) { .app { grid-template-columns: minmax(0, 1fr); } - .sidebar { - position: fixed; inset: 0 auto 0 0; width: min(86vw, var(--nav-w)); - z-index: 60; transform: translateX(-100%); - box-shadow: 2px 0 24px rgba(0,0,0,.18); - } .content { grid-column: 1; } - .hamburger { display: inline-flex; } - - /* checkbox-driven open state */ - body:has(#nav-toggle:checked) .sidebar { transform: translateX(0); } - body:has(#nav-toggle:checked) .scrim { - display: block; position: fixed; inset: 0; z-index: 50; - background: rgba(0,0,0,.42); - } .search { min-width: 150px; flex: 1 1 auto; } + + @supports selector(:has(a)) { + .sidebar { + position: fixed; inset: 0 auto 0 0; width: min(86vw, var(--nav-w)); + z-index: 60; transform: translateX(-100%); + box-shadow: 2px 0 24px rgba(0,0,0,.18); + } + .hamburger { display: inline-flex; } + + /* checkbox-driven open state */ + body:has(#nav-toggle:checked) .sidebar { transform: translateX(0); } + body:has(#nav-toggle:checked) .scrim { + display: block; position: fixed; inset: 0; z-index: 50; + background: rgba(0,0,0,.42); + } + } } @media (max-width: 560px) { .crumbs, .page-sub { display: none; } diff --git a/src/ui/stylesheet-floor.test.ts b/src/ui/stylesheet-floor.test.ts index 1de3e77..d651fd9 100644 --- a/src/ui/stylesheet-floor.test.ts +++ b/src/ui/stylesheet-floor.test.ts @@ -16,6 +16,16 @@ for (const file of readdirSync(cssDir).filter((f) => f.endsWith(".css"))) { const stylesheet = readFileSync(join(cssDir, "styles.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); +// The index of the `}` closing the block whose `{` is the first one at or after `start`. +function blockEnd(start: number): number { + let depth = 0; + for (let i = stylesheet.indexOf("{", start); i < stylesheet.length; i++) { + if (stylesheet[i] === "{") depth++; + if (stylesheet[i] === "}" && --depth === 0) return i; + } + throw new Error("unbalanced braces in styles.css"); +} + // The text is all there is to check: Playwright's engines all support `:has()`, so no E2E sees the // stylesheet an older engine gets. // Chromium below 105 and Firefox below 121 have no `:has()`, and nothing else reveals a folded @@ -23,12 +33,7 @@ const stylesheet = readFileSync(join(cssDir, "styles.css"), "utf8").replace(/\/\ test("styles.css folds .nav-children only inside @supports selector(:has(a))", () => { const open = stylesheet.indexOf("@supports selector(:has(a)) {"); assert.ok(open >= 0, "no @supports selector(:has(a)) block"); - let depth = 0; - let close = open; - for (; close < stylesheet.length; close++) { - if (stylesheet[close] === "{") depth++; - if (stylesheet[close] === "}" && --depth === 0) break; - } + const close = blockEnd(open); const outside = stylesheet.slice(0, open) + stylesheet.slice(close + 1); assert.ok(!/\.nav-children\s*\{[^}]*display:\s*none/.test(outside), "a .nav-children rule hides it outside the guard"); assert.match(stylesheet.slice(open, close), /\.nav-children\s*\{\s*display:\s*none/, "the fold rule is gone"); @@ -44,3 +49,21 @@ test("styles.css hides the theme switch and the nav toggles where :has() is miss assert.match(block, /\.theme-switch\s*\{\s*display:\s*none/); assert.match(block, /\.nav-disc\s*\{\s*visibility:\s*hidden/); }); + +// Without `:has()` nothing can check #nav-toggle, so an off-canvas sidebar there is unreachable +// and the hamburger does nothing: the sidebar has to stay in the flow, with the hamburger hidden. +test("styles.css moves the sidebar off-canvas at 860px only inside @supports selector(:has(a))", () => { + const open = stylesheet.indexOf("@media (max-width: 860px) {"); + assert.ok(open >= 0, "no @media (max-width: 860px) block"); + const close = blockEnd(open); + const media = stylesheet.slice(open, close); + const guard = media.indexOf("@supports selector(:has(a)) {"); + assert.ok(guard >= 0, "no @supports selector(:has(a)) block inside the 860px media block"); + const guardEnd = blockEnd(open + guard) - open; + const outside = media.slice(0, guard) + media.slice(guardEnd + 1); + const inside = media.slice(guard, guardEnd); + for (const offCanvas of [/translateX\(-100%\)/, /position:\s*fixed/, /#nav-toggle/, /\.hamburger/]) { + assert.doesNotMatch(outside, offCanvas, `${offCanvas} applies outside the guard`); + assert.match(inside, offCanvas, `${offCanvas} is gone from the guard`); + } +}); diff --git a/todo.md b/todo.md index a41468f..26b3c3d 100644 --- a/todo.md +++ b/todo.md @@ -12,8 +12,6 @@ | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | |---|---|---|---|---|---|---|---|---|---|---|---| -| 36 | 0.4.21 | | defect | **Keep the mobile nav reachable at 860px and narrower on an engine without `:has()`, and move it off-canvas only where `:has()` works.** | 2 | 2 | 2 | 4 | 3 | 4 by 6 | 26.9 | -| 37 | 0.4.21 | | | **Replace the stale architecture comment at the top of `public/css/styles.css` so it no longer says the design tokens use `light-dark()`.** | 1 | 2 | 1 | 2 | 1 | code health by 2 | 14.4 | | 26 | 0.4.21 | | | **Add a domain glossary and the project's intended horizon to the README.** | 1 | 2 | 1 | 2 | 3 | 9 by 3, code health by 2 | 11.9 | | 8 | 0.4.21 | | | **Run the visual E2E specs at viewports from a large desktop down to a small phone.** | 2 | 1 | 1 | 2 | 4 | code health by 3 | 9.0 | | 9 | 0.4.21 | | | **Typecheck `e2e-tests/` in the gate.** | 1 | 1 | 1 | 1 | 3 | code health by 2 | 9.0 | @@ -40,23 +38,6 @@ ## Details -### 36. Keep the mobile nav reachable at 860px and narrower on an engine without `:has()`, and move it off-canvas only where `:has()` works. - -README → "Two browser floors": Chromium 87 must reach every page, and `:has()` may only decorate. -In `public/css/styles.css`, `@media (max-width: 860px)` moves `.sidebar` off-canvas with -`transform: translateX(-100%)`, and only `body:has(#nav-toggle:checked)` brings it and `.scrim` -back. Below Chromium 105 (webOS TV 22 and 23) the hamburger does nothing at that width, so no page -behind the nav can be reached. Move the off-canvas rules inside `@supports selector(:has(a))`, and -keep the sidebar in the flow outside it, with the hamburger hidden. The desktop tree and the theme -switch are already guarded. Test it beside `src/ui/stylesheet-floor.test.ts`. - -### 37. Replace the stale architecture comment at the top of `public/css/styles.css` so it no longer says the design tokens use `light-dark()`. - -The header (lines 4-6) says the tokens use `light-dark()`, but the TOKENS comment below it says the -stylesheet avoids `light-dark()` and `color-scheme` for app colours, because engines resolve them -unreliably, and maps raw `--l-*`/`--d-*` palettes in three theme blocks instead. A reader trusting -the header looks for `light-dark()` that does not exist. Found by a comprehension-panel reader. - ### 26. Add a domain glossary and the project's intended horizon to the README. Glossary: host, manifest, chrome, nav fragment, permission token, system plugin, denylist. Also the -- 2.52.0 From 21a00f895a6d1f0c69b884ff104a8df29773bd24 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 02:04:47 +0100 Subject: [PATCH 2/6] =?UTF-8?q?Answer=20stability=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=201=20=F0=9F=9F=A1=20(0=20misread);=20test=20the?= =?UTF-8?q?=20drawer=20opens=20and=20a=20link=20in=20it=20navigates,=20kee?= =?UTF-8?q?p=20the=20in-flow=20sidebar=20from=20stretching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e-tests/visual.spec.ts | 15 +++++++++++++++ public/css/styles.css | 11 ++++------- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/e2e-tests/visual.spec.ts b/e2e-tests/visual.spec.ts index 4f6c164..3ef4363 100644 --- a/e2e-tests/visual.spec.ts +++ b/e2e-tests/visual.spec.ts @@ -148,6 +148,21 @@ test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ expect(offCanvas).toBe(true); }); +test("the hamburger opens the drawer over the scrim, and a link in it navigates", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/dashboard"); + await page.locator(".hamburger").click(); + + const sidebar = page.locator(".sidebar"); + await expect(sidebar).toBeInViewport({ ratio: 1 }); + // A click is refused while the scrim sits above the drawer, which a lower z-index would cause. + await sidebar.locator("summary").first().click(); // the dashboard is the current page, so its links are in a folded branch + const link = sidebar.locator(".nav-children a.nav-self").first(); + const target = await link.getAttribute("href"); + await link.click(); + await expect(page).toHaveURL((url) => url.pathname === target?.split("?")[0]); +}); + test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a tokenless POST is refused", async ({ page }) => { await page.goto("/dashboard"); // The page issues a CSRF cookie and embeds the same token in the Sign-out form (double-submit). diff --git a/public/css/styles.css b/public/css/styles.css index 5385632..2b71a45 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -2,9 +2,6 @@ App Shell Template — pure HTML/CSS, no JavaScript ------------------------------------------------------------- Architecture - - Design tokens are two raw palettes picked by a theme block, so - the theme follows the system by default and can be forced - Light / Dark via radio + :has(). - Everything is a reusable "block": .nav-item, .filter, .chip, .badge, .btn, .menu, .table — combine freely. ============================================================= */ @@ -682,13 +679,13 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); } /* ---------- 11. RESPONSIVE / HAMBURGER --------------------- */ .scrim { display: none; } -/* Off-canvas only where :has() works: without it nothing can read #nav-toggle, so the sidebar - stays in the flow above the content and the hamburger stays hidden. */ @media (max-width: 860px) { - .app { grid-template-columns: minmax(0, 1fr); } - .content { grid-column: 1; } + .app { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto 1fr; } + .content { grid-column: 1; grid-row: 2; } .search { min-width: 150px; flex: 1 1 auto; } + /* Off-canvas only where :has() works: without it nothing can read #nav-toggle, so the sidebar + stays in the flow above the content and the hamburger stays hidden. */ @supports selector(:has(a)) { .sidebar { position: fixed; inset: 0 auto 0 0; width: min(86vw, var(--nav-w)); -- 2.52.0 From 9e5b60a33366064c7cdf79f822b1a7c59a308a17 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 02:04:47 +0100 Subject: [PATCH 3/6] =?UTF-8?q?Answer=20product-owner=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=200=20=F0=9F=9F=A1=20(0=20misread);=20name=20Fire?= =?UTF-8?q?fox=20in=20the=20changelog=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -- 2.52.0 From 6815caa47eb7dd408d9de42ebbc77e38876fbe40 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 02:04:47 +0100 Subject: [PATCH 4/6] =?UTF-8?q?Answer=20prose=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=204=20=F0=9F=9F=A1=20(0=20misread);=20drop=20the?= =?UTF-8?q?=20false=20header=20bullet,=20match=20the=20changelog=20and=20t?= =?UTF-8?q?est=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 +++++---- src/ui/stylesheet-floor.test.ts | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3376a9..21666af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,11 @@ at boot. Entries start at 0.3.0. ### The mobile nav stays reachable on engines without `:has()` -At 860px and narrower, an engine without `:has()` (Chromium below 105, such as webOS TV 22 and 23) -hid the sidebar off-canvas with no way to open it, so no page behind the nav could be reached. Now -the sidebar moves off-canvas, behind the hamburger, only where `:has()` works. Elsewhere it stays in -the flow above the content and the hamburger is hidden. +At 860px and narrower, an engine without `:has()` (Chromium below 105, such as webOS TV 22 and 23, +and Firefox below 121) hid the sidebar off-canvas with no way to open it, so the pages the nav links +to could not be reached. Now the sidebar moves off-canvas, behind the hamburger, only where `:has()` +works. Elsewhere it stays in the flow above the content and the hamburger is hidden. + ### The whole nav tree shows on engines without `:has()` On an engine without `:has()` (Chromium below 105, such as webOS TV 22 and 23, and Firefox below 121) diff --git a/src/ui/stylesheet-floor.test.ts b/src/ui/stylesheet-floor.test.ts index d651fd9..e37bfa0 100644 --- a/src/ui/stylesheet-floor.test.ts +++ b/src/ui/stylesheet-floor.test.ts @@ -50,8 +50,8 @@ test("styles.css hides the theme switch and the nav toggles where :has() is miss assert.match(block, /\.nav-disc\s*\{\s*visibility:\s*hidden/); }); -// Without `:has()` nothing can check #nav-toggle, so an off-canvas sidebar there is unreachable -// and the hamburger does nothing: the sidebar has to stay in the flow, with the hamburger hidden. +// Without `:has()` nothing reads #nav-toggle, so an off-canvas sidebar there is unreachable and the +// hamburger does nothing: the sidebar has to stay in the flow, with the hamburger hidden. test("styles.css moves the sidebar off-canvas at 860px only inside @supports selector(:has(a))", () => { const open = stylesheet.indexOf("@media (max-width: 860px) {"); assert.ok(open >= 0, "no @media (max-width: 860px) block"); -- 2.52.0 From 0f20b386e2fd407046f33545d5cecad56ce153c1 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 02:04:47 +0100 Subject: [PATCH 5/6] File the stale TOKENS comment in the plan --- todo.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/todo.md b/todo.md index 26b3c3d..a1c3da1 100644 --- a/todo.md +++ b/todo.md @@ -6,7 +6,7 @@ `Bar = 1` -`Next ID = 38` +`Next ID = 39` ## Items @@ -18,6 +18,7 @@ | 24 | 0.4.21 | | | **Retire `src/ui/shell-context.ts` and render the dashboard from `ctx.chrome`.** | 2 | 1 | 2 | 3 | 4 | code health by 3 | 9.0 | | 17 | 0.4.21 | | | **Remove the icons no caller uses from `ICON_NAMES` and the sprite.** | 1 | 1 | 1 | 1 | 1 | 8 by 1, code health by 1 | 8.8 | | 21 | 0.4.21 | | | **Fail the gate on a function that exceeds a size or complexity limit.** | 3 | 1 | 1 | 3 | 5 | code health by 4 | 7.2 | +| 38 | 0.4.21 | | | **Correct the TOKENS comment at the top of `public/css/styles.css` so it no longer claims `:has()` swaps are "rock-solid" or that the theme follows the OS on every engine.** | 1 | 2 | 1 | 2 | 1 | code health by 1 | 7.2 | | 32 | 0.4.21 | | | **Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded.** | 3 | 1 | 1 | 3 | 4 | code health by 2 | 6.0 | | 20 | 0.4.21 | | | **Redirect an anonymous hit on a session-needing flow page to sign-in, instead of a 500.** | 2 | 2 | 2 | 4 | 3 | 2 by 4 | 4.2 | | 4 | 0.4.21 | | | **Refuse a permission save whose baseline no longer matches Keto, so concurrent edits are not silently lost.** | 2 | 2 | 2 | 4 | 4 | 2 by 4 | 2.8 | @@ -58,6 +59,14 @@ AGENTS.md → "`ICON_NAMES` is a host-owned registry" already makes this routine Only `tsc --strict` runs today. A new tool is a new dependency, so justify it. +### 38. Correct the TOKENS comment at the top of `public/css/styles.css` so it no longer claims `:has()` swaps are "rock-solid" or that the theme follows the OS on every engine. + +Section 1's TOKENS comment says the theme follows the OS by default and that `:has()` variable swaps +are "rock-solid". On an engine without `:has()` (Chromium below 105, Firefox below 121) every theme +rule is inert, the OS-dark rule included, so those engines stay light, as the +`@supports not selector(:has(a))` block further down says. A reader trusting the comment expects dark +mode there. + ### 32. Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded. Nothing checks README → "Two browser floors", and no E2E engine is old enough to show a break. -- 2.52.0 From bb8e6bc8e8876312620ae4ee3c9ec7f238997ca9 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 02:09:41 +0100 Subject: [PATCH 6/6] Record the comprehension check: paired ruling better for PR #183 --- docs/comprehension-history.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/comprehension-history.md b/docs/comprehension-history.md index 62dec9c..4c560a8 100644 --- a/docs/comprehension-history.md +++ b/docs/comprehension-history.md @@ -43,3 +43,22 @@ Inherited architect, decided by: - Locality, Overall: `public/css/styles.css:31` `--l-on-accent`: one token feeds `.btn-primary`, `.page-btn[aria-current]` and `.brand-mark` - Locality: `public/css/styles.css:287` `@supports not selector(:has(a))`: its `.nav-disc` rule sits about 85 lines from the nav rules it overrides, and `stylesheet-floor.test.ts` guards the order for `.theme-switch` only - Self-sufficiency: `src/ui/stylesheet-contrast.test.ts:29` `stylesheet-contrast.test.ts`: the darker badge colours now have a stated 4.5:1 reason someone can find + +## 2026-10-31T01:09:38Z, PR #183 at 0f20b38, against 364b047 + +Ruling: better + +| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall | +|---|---|---|---|---|---| +| Mid A | same | same | same | better | better | +| Inherited architect | same | better | same | better | better | + +Mid A, decided by: + +- Self-sufficiency, Overall: `public/css/styles.css:1` `App Shell Template header`: the false `light-dark()` bullet is gone, so no reader has to work out which of two comments is lying +- Self-sufficiency, Overall: `public/css/styles.css:687` `@supports selector(:has(a))` in `@media (max-width: 860px)`: the guard and its comment say what an engine without `:has()` gets, without opening the CHANGELOG + +Inherited architect, decided by: + +- Self-sufficiency, Overall: `public/css/styles.css:5` `Architecture` header: the false `light-dark()` bullet is gone, so the header no longer contradicts the TOKENS comment +- Locality, Self-sufficiency, Overall: `public/css/styles.css:687` `@supports selector(:has(a))` in `@media (max-width: 860px)`: the guard and its comment put the dependency of the open state on `:has()` where an editor would change it -- 2.52.0