diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fd8fd4..af019bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ at boot. Entries start at 0.3.0. ## Unreleased +### 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) +every nested nav entry was hidden with nothing to open it. Now the tree folds only where `:has()` +works and stays fully open elsewhere, with its toggles hidden. The theme switch, which also needs +`:has()`, is hidden there. + +### Button and badge text meets 4.5:1 contrast + +The primary button and the current page button take their text colour from the new `--on-accent` +token: white in light mode, near-black in dark mode, where white on the accent was 2.7:1. The +light-mode success, warning and info badge text is darker. A plugin that overrides `--accent` or +`--l-accent` should set `--l-on-accent` and `--d-on-accent` to match. + +### Unused auth stylesheet rules are gone + +`public/css/auth.css` no longer holds the `.auth-view` screen switch, `.tpl-*` preview controls, +`.forgot-alert`, `.register-alert`, `.remember` and `.auth-foot`; no core view used them. A plugin +view that did must carry its own rules. + ### Colours render on engines without `oklch()` The theme palette in `public/css/styles.css` is hex, not `oklch()`. On an engine older than diff --git a/docs/comprehension-history.md b/docs/comprehension-history.md index 67ce870..62dec9c 100644 --- a/docs/comprehension-history.md +++ b/docs/comprehension-history.md @@ -19,3 +19,27 @@ Inherited architect, decided by: - Self-sufficiency, Locality: `public/css/styles.css:24` `:root`: the comment above the hex palette says why a colour must not be oklch(), where a colour is edited - Locality: `src/ui/stylesheet-floor.test.ts:11` `declares no oklch() colour`: a red gate explains the symptom in the file it lands on - Self-sufficiency: `README.md:1027` "Two browser floors": the colour rule now sits beside the floor rule + +## 2026-10-31T00:43:19Z, PR #182 at 19987ba, against 3e70768 + +Ruling: better + +| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall | +|---|---|---|---|---|---| +| Mid A | better | better | better | better | better | +| Inherited architect | better | better | better | better | better | + +Mid A, decided by: + +- Navigation, Shape, Self-sufficiency, Overall: `public/css/auth.css:1` `auth.css`: about 120 lines of rules for markup no view renders are gone, so a cold reader no longer rules them out first +- Locality, Self-sufficiency, Overall: `public/css/styles.css:31` `--on-accent`: the text colour on the accent is one token where four hard-coded `#fff` stood +- Self-sufficiency, Navigation, Overall: `public/css/styles.css:287` `@supports not selector(:has(a))`: the nav comment, the partial's header and the rule all say the tree folds only where `:has()` works +- Locality: `public/css/styles.css:241` `@supports selector(:has(a))`: the nav-fold behaviour now sits in two blocks about 50 lines apart, the second order-dependent; the comment names it + +Inherited architect, decided by: + +- Navigation, Shape, Overall: `public/css/auth.css:1` `auth.css`: the false `:target` banner and the phantom state machine are gone, so every selector left names something a view emits +- Self-sufficiency, Overall: `views/partials/nav-tree.ejs:4` `nav-tree.ejs`: the header and `public/css/styles.css:194` now say the branch folds only where `:has()` works; the old text was untrue on old engines +- 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 diff --git a/docs/reading-cost.md b/docs/reading-cost.md index d281e8b..6a5a028 100644 --- a/docs/reading-cost.md +++ b/docs/reading-cost.md @@ -16,3 +16,17 @@ Read most (opens and searches): - `ci.sh` 1 - `examples/plugins/scheduling/views/shifts.ejs` 1 - `src/auth/csrf.ts` 1 + +## Reading cost, chunk df5e30a4db5f, finished 2026-10-31T00:22:00Z + +80251 new input tokens before the first commit. + +Read most (opens and searches): + +- `todo.md` 5 +- `README.md` 3 +- `CHANGELOG.md` 2 +- `public/css/styles.css` 2 +- `AGENTS.md` 1 +- `src/ui/stylesheet-floor.test.ts` 1 +- `src/ui/theme-switch.test.ts` 1 diff --git a/e2e-tests/visual.spec.ts b/e2e-tests/visual.spec.ts index ce75af7..4f6c164 100644 --- a/e2e-tests/visual.spec.ts +++ b/e2e-tests/visual.spec.ts @@ -174,6 +174,50 @@ test("the public landing at / is ungated and links to sign in + register", async await shot(page, "live-05-public-landing"); }); +// Measured on the rendered elements, so a theme block that drops --on-accent fails here. +const contrastOf = (el: Element): number => { + const luminance = (rgb: string): number => { + const [r = 0, g = 0, b = 0] = (rgb.match(/[\d.]+/g) ?? []).map((c) => { + const v = Number(c) / 255; + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }; + const style = getComputedStyle(el); + const [hi = 0, lo = 0] = [luminance(style.color), luminance(style.backgroundColor)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +}; +for (const [name, colorScheme, forceDark] of [["light", "light", false], ["dark", "dark", false], ["forced dark", "light", true]] as const) { + test(`text on the accent reaches 4.5:1 in ${name} mode`, async ({ page, context }) => { + await context.clearCookies(); + await page.emulateMedia({ colorScheme }); + await page.goto("/"); + if (forceDark) await page.locator("#theme-dark").check({ force: true }); + // No page of this stack has a second page of results, so add the current-page button. + await page.locator("#main-content").evaluate((el) => el.insertAdjacentHTML("beforeend", '2')); + for (const selector of [".landing .btn-primary", ".brand-mark", ".page-btn"]) { + await expect.poll(() => page.locator(selector).first().evaluate(contrastOf), { message: selector }).toBeGreaterThanOrEqual(4.5); // polls past the colour transition + } + }); +} + +test("a nav branch is folded until its toggle opens it — no JavaScript", async ({ page }) => { + await page.goto("/dashboard"); + const child = page.locator('.sidebar a[href="/scheduling"]'); + await expect(child).toBeHidden(); + await page.locator('.sidebar summary[aria-label="Toggle Scheduling"]').click(); + await expect(child).toBeVisible(); +}); + +test("the reference plugin's lead text is inset by --pad-x, like the bars around it", async ({ page }) => { + await page.goto("/scheduling"); + const [inset, padX] = await page.locator(".scheduling-page > p").evaluate((p) => [ + p.getBoundingClientRect().left - (p.parentElement as HTMLElement).getBoundingClientRect().left, + parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--pad-x")), + ] as const); + expect(inset).toBe(padX); +}); + test("unknown routes serve the 404 page (a real user-facing flow, covered end-to-end)", async ({ page }) => { allowConsole(/status of 404 .*\/no-such-page$/); // the navigation under test, which Chromium and WebKit log — not a sub-resource of it const res = await page.goto("/no-such-page"); diff --git a/examples/plugins/scheduling/public/scheduling.css b/examples/plugins/scheduling/public/scheduling.css index d284249..46a2499 100644 --- a/examples/plugins/scheduling/public/scheduling.css +++ b/examples/plugins/scheduling/public/scheduling.css @@ -4,3 +4,9 @@ .scheduling-page .table-wrap { margin-top: var(--space-3, 0.75rem); } +/* Inset what sits directly in the pane by --pad-x, as the filter bar, table and pager already are. */ +.scheduling-page > p, +.scheduling-page > .alert, +.scheduling-page > .btn { + margin-inline: var(--pad-x); +} diff --git a/public/css/auth.css b/public/css/auth.css index dbee49f..b8fbe20 100644 --- a/public/css/auth.css +++ b/public/css/auth.css @@ -1,11 +1,3 @@ -/* ============================================================= - Auth flow — login / register / forgot password - Built on the shared tokens in styles.css. Pure HTML/CSS: - - screens switch via :target (no JS) - - SSO is a config section toggled by a class/checkbox; providers - are a
under any field and gate - it on whatever state your server sets. - ============================================================= */ .field-error { display: none; align-items: center; gap: 6px; margin: 0; font-size: var(--fz-xs); color: var(--neg); @@ -213,36 +128,3 @@ body:has(#forgot:target) .tpl-forgot { display: flex; } .field.has-error .field-error { display: flex; } /* server-rendered field shows its own error */ .field-error > .ico { flex: 0 0 auto; color: var(--neg); } .field-error a { color: var(--neg); font-weight: 600; text-decoration: underline; } - -/* register form-level error banner (pairs with field-level errors) */ -.register-alert { display: none; } - -/* --- state A: email already taken — field-level error only --- */ -#register.state-email-taken .err-email, -body:has(#rstate-taken:checked) #register .err-email { display: flex; } -#register.state-email-taken #reg-email, -body:has(#rstate-taken:checked) #register #reg-email { border-color: var(--neg); } - -/* --- state B: multiple errors — banner + several field errors together --- */ -#register.state-invalid .register-alert, -body:has(#rstate-combined:checked) #register .register-alert { display: flex; } -#register.state-invalid .err-email, -#register.state-invalid .err-password, -body:has(#rstate-combined:checked) #register .err-email, -body:has(#rstate-combined:checked) #register .err-password { display: flex; } -#register.state-invalid #reg-email, -#register.state-invalid #reg-password, -body:has(#rstate-combined:checked) #register #reg-email, -body:has(#rstate-combined:checked) #register #reg-password { border-color: var(--neg); } - -/* red focus ring on any invalid register input */ -#register.state-email-taken #reg-email:focus, -#register.state-invalid #reg-email:focus, -#register.state-invalid #reg-password:focus, -body:has(#rstate-taken:checked) #register #reg-email:focus, -body:has(#rstate-combined:checked) #register #reg-email:focus, -body:has(#rstate-combined:checked) #register #reg-password:focus { box-shadow: 0 0 0 2px var(--neg-bg); } - -/* the Register-state control only appears while the register view is open */ -.tpl-register { display: none; } -body:has(#register:target) .tpl-register { display: flex; } diff --git a/public/css/styles.css b/public/css/styles.css index 8074e4a..77c4eea 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -28,18 +28,18 @@ --l-border:#e5e5e8; --l-border-2:#d6d6da; --l-text:#18181b; --l-text-muted:#6b6b73; --l-text-faint:#9a9aa2; --l-accent:#3669ac; --l-accent-bg:#e5f3ff; - --l-accent-bd:#a7c7f2; --l-focus:#2a68b7; - --l-pos:#258343; --l-pos-bg:#e1f5e4; --l-pos-bd:#a8d5b0; + --l-accent-bd:#a7c7f2; --l-focus:#2a68b7; --l-on-accent:#ffffff; + --l-pos:#1f7a3d; --l-pos-bg:#e1f5e4; --l-pos-bd:#a8d5b0; --l-neg:#c53637; --l-neg-bg:#ffebe8; --l-neg-bd:#febab4; - --l-warn:#9a6500; --l-warn-bg:#fef0d4; --l-warn-bd:#e3c78e; - --l-info:#2274af;--l-info-bg:#e2f5ff;--l-info-bd:#a2cdf2; + --l-warn:#8f5d00; --l-warn-bg:#fef0d4; --l-warn-bd:#e3c78e; + --l-info:#1d6aa5;--l-info-bg:#e2f5ff;--l-info-bd:#a2cdf2; /* ---- raw DARK palette ---- */ --d-bg:#0d0d0f; --d-surface:#161619; --d-surface-2:#1d1d22; --d-surface-3:#1a1a1e; --d-border:#2a2a31; --d-border-2:#3a3a43; --d-text:#ededf0; --d-text-muted:#9b9ba6; --d-text-faint:#6a6a74; --d-accent:#6ba0e8; --d-accent-bg:#1d3351; - --d-accent-bd:#3f5f8a; --d-focus:#72adfb; + --d-accent-bd:#3f5f8a; --d-focus:#72adfb; --d-on-accent:#0d0d0f; --d-pos:#62c37a; --d-pos-bg:#1a3520; --d-pos-bd:#376040; --d-neg:#ff7e76; --d-neg-bg:#4b1d1b; --d-neg-bd:#8a423d; --d-warn:#e4b750; --d-warn-bg:#3e2d10; --d-warn-bd:#72592c; @@ -49,7 +49,7 @@ --bg:var(--l-bg); --surface:var(--l-surface); --surface-2:var(--l-surface-2); --surface-3:var(--l-surface-3); --border:var(--l-border); --border-2:var(--l-border-2); --text:var(--l-text); --text-muted:var(--l-text-muted); --text-faint:var(--l-text-faint); - --accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus); + --accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus); --on-accent:var(--l-on-accent); --pos:var(--l-pos); --pos-bg:var(--l-pos-bg); --pos-bd:var(--l-pos-bd); --neg:var(--l-neg); --neg-bg:var(--l-neg-bg); --neg-bd:var(--l-neg-bd); --warn:var(--l-warn); --warn-bg:var(--l-warn-bg); --warn-bd:var(--l-warn-bd); @@ -76,7 +76,7 @@ --bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3); --border:var(--d-border); --border-2:var(--d-border-2); --text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint); - --accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus); + --accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus); --on-accent:var(--d-on-accent); --pos:var(--d-pos); --pos-bg:var(--d-pos-bg); --pos-bd:var(--d-pos-bd); --neg:var(--d-neg); --neg-bg:var(--d-neg-bg); --neg-bd:var(--d-neg-bd); --warn:var(--d-warn); --warn-bg:var(--d-warn-bg); --warn-bd:var(--d-warn-bd); @@ -90,7 +90,7 @@ html:has(#theme-dark:checked) { --bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3); --border:var(--d-border); --border-2:var(--d-border-2); --text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint); - --accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus); + --accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus); --on-accent:var(--d-on-accent); --pos:var(--d-pos); --pos-bg:var(--d-pos-bg); --pos-bd:var(--d-pos-bd); --neg:var(--d-neg); --neg-bg:var(--d-neg-bg); --neg-bd:var(--d-neg-bd); --warn:var(--d-warn); --warn-bg:var(--d-warn-bg); --warn-bd:var(--d-warn-bd); @@ -103,7 +103,7 @@ html:has(#theme-light:checked) { --bg:var(--l-bg); --surface:var(--l-surface); --surface-2:var(--l-surface-2); --surface-3:var(--l-surface-3); --border:var(--l-border); --border-2:var(--l-border-2); --text:var(--l-text); --text-muted:var(--l-text-muted); --text-faint:var(--l-text-faint); - --accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus); + --accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus); --on-accent:var(--l-on-accent); --pos:var(--l-pos); --pos-bg:var(--l-pos-bg); --pos-bd:var(--l-pos-bd); --neg:var(--l-neg); --neg-bg:var(--l-neg-bg); --neg-bd:var(--l-neg-bd); --warn:var(--l-warn); --warn-bg:var(--l-warn-bg); --warn-bd:var(--l-warn-bd); @@ -171,9 +171,9 @@ summary { list-style: none; cursor: pointer; } .brand-mark { width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto; background: var(--accent); - display: grid; place-items: center; color: #fff; + display: grid; place-items: center; color: var(--on-accent); } -.brand-mark .ico { stroke: #fff; } +.brand-mark .ico { stroke: var(--on-accent); } .brand-logo { width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto; object-fit: contain; } .brand-name { font-weight: 650; letter-spacing: -.01em; } .brand-sub { color: var(--text-faint); font-size: var(--fz-xs); margin-left: auto; } @@ -191,9 +191,10 @@ summary { list-style: none; cursor: pointer; } • CLICKABLE = label is → navigates on click. STATIC = label is → not a navigation target. So "header" and "clickable" are independent: any combination works. - Children sit in a sibling