Keep the mobile nav reachable on engines without :has(), and fix the stylesheet header
CI / full-gate (push) Successful in 3m8s
CI / full-gate (push) Successful in 3m8s
This commit is contained in:
@@ -6,6 +6,12 @@ at boot. Entries start at 0.3.0.
|
|||||||
|
|
||||||
## Unreleased
|
## 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()`
|
### 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)
|
On an engine without `:has()` (Chromium below 105, such as webOS TV 22 and 23, and Firefox below 121)
|
||||||
|
|||||||
@@ -30,3 +30,20 @@ Read most (opens and searches):
|
|||||||
- `AGENTS.md` 1
|
- `AGENTS.md` 1
|
||||||
- `src/ui/stylesheet-floor.test.ts` 1
|
- `src/ui/stylesheet-floor.test.ts` 1
|
||||||
- `src/ui/theme-switch.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
|
||||||
|
|||||||
+21
-15
@@ -2,8 +2,9 @@
|
|||||||
App Shell Template — pure HTML/CSS, no JavaScript
|
App Shell Template — pure HTML/CSS, no JavaScript
|
||||||
-------------------------------------------------------------
|
-------------------------------------------------------------
|
||||||
Architecture
|
Architecture
|
||||||
- Design tokens via light-dark() so theme follows system by
|
- Design tokens are two raw palettes picked by a theme block, so
|
||||||
default and can be forced Light / Dark via radio + :has().
|
the theme follows the system by default and can be forced
|
||||||
|
Light / Dark via radio + :has().
|
||||||
- Everything is a reusable "block": .nav-item, .filter,
|
- Everything is a reusable "block": .nav-item, .filter,
|
||||||
.chip, .badge, .btn, .menu, .table — combine freely.
|
.chip, .badge, .btn, .menu, .table — combine freely.
|
||||||
============================================================= */
|
============================================================= */
|
||||||
@@ -681,23 +682,28 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
|||||||
/* ---------- 11. RESPONSIVE / HAMBURGER --------------------- */
|
/* ---------- 11. RESPONSIVE / HAMBURGER --------------------- */
|
||||||
.scrim { display: none; }
|
.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) {
|
@media (max-width: 860px) {
|
||||||
.app { grid-template-columns: minmax(0, 1fr); }
|
.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; }
|
.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; }
|
.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) {
|
@media (max-width: 560px) {
|
||||||
.crumbs, .page-sub { display: none; }
|
.crumbs, .page-sub { display: none; }
|
||||||
|
|||||||
@@ -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, "");
|
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
|
// The text is all there is to check: Playwright's engines all support `:has()`, so no E2E sees the
|
||||||
// stylesheet an older engine gets.
|
// stylesheet an older engine gets.
|
||||||
// Chromium below 105 and Firefox below 121 have no `:has()`, and nothing else reveals a folded
|
// 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))", () => {
|
test("styles.css folds .nav-children only inside @supports selector(:has(a))", () => {
|
||||||
const open = stylesheet.indexOf("@supports selector(:has(a)) {");
|
const open = stylesheet.indexOf("@supports selector(:has(a)) {");
|
||||||
assert.ok(open >= 0, "no @supports selector(:has(a)) block");
|
assert.ok(open >= 0, "no @supports selector(:has(a)) block");
|
||||||
let depth = 0;
|
const close = blockEnd(open);
|
||||||
let close = open;
|
|
||||||
for (; close < stylesheet.length; close++) {
|
|
||||||
if (stylesheet[close] === "{") depth++;
|
|
||||||
if (stylesheet[close] === "}" && --depth === 0) break;
|
|
||||||
}
|
|
||||||
const outside = stylesheet.slice(0, open) + stylesheet.slice(close + 1);
|
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.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");
|
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, /\.theme-switch\s*\{\s*display:\s*none/);
|
||||||
assert.match(block, /\.nav-disc\s*\{\s*visibility:\s*hidden/);
|
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`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,8 +12,6 @@
|
|||||||
|
|
||||||
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
## 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.
|
### 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
|
Glossary: host, manifest, chrome, nav fragment, permission token, system plugin, denylist. Also the
|
||||||
|
|||||||
Reference in New Issue
Block a user