Show the whole nav tree without :has(), raise text contrast to 4.5:1, drop unused auth rules #182

Merged
lilleman merged 11 commits from item-29 into main 2026-10-31 01:47:51 +01:00
11 changed files with 224 additions and 163 deletions
+20
View File
@@ -6,6 +6,26 @@ at boot. Entries start at 0.3.0.
## Unreleased ## 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()` ### Colours render on engines without `oklch()`
The theme palette in `public/css/styles.css` is hex, not `oklch()`. On an engine older than The theme palette in `public/css/styles.css` is hex, not `oklch()`. On an engine older than
+24
View File
@@ -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 - 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 - 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 - 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
+14
View File
@@ -16,3 +16,17 @@ Read most (opens and searches):
- `ci.sh` 1 - `ci.sh` 1
- `examples/plugins/scheduling/views/shifts.ejs` 1 - `examples/plugins/scheduling/views/shifts.ejs` 1
- `src/auth/csrf.ts` 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
+44
View File
@@ -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"); 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", '<a class="page-btn" aria-current="page" href="#">2</a>'));
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 }) => { 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 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"); const res = await page.goto("/no-such-page");
@@ -4,3 +4,9 @@
.scheduling-page .table-wrap { .scheduling-page .table-wrap {
margin-top: var(--space-3, 0.75rem); 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);
}
-118
View File
@@ -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 <ul> you extend by adding one <li class="sso-item">.
============================================================= */
.auth-stage { .auth-stage {
min-height: 100dvh; min-height: 100dvh;
display: grid; place-items: center; display: grid; place-items: center;
@@ -34,13 +26,6 @@
.landing-actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 4px; } .landing-actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 4px; }
.landing-actions .btn { height: 40px; padding: 0 20px; font-size: var(--fz); } .landing-actions .btn { height: 40px; padding: 0 20px; font-size: var(--fz); }
/* ---- screen switching via :target (login is the default) ---- */
.auth-view { display: none; }
#login { display: block; }
#register:target, #forgot:target { display: block; }
body:has(#register:target) #login,
body:has(#forgot:target) #login { display: none; }
/* card */ /* card */
.auth-card { .auth-card {
background: var(--surface); background: var(--surface);
@@ -123,8 +108,6 @@ body:has(#forgot:target) #login { display: none; }
.field-hint { font-size: var(--fz-xs); color: var(--text-faint); } .field-hint { font-size: var(--fz-xs); color: var(--text-faint); }
.remember { margin-top: 2px; }
/* full-width primary button (extends .btn / .btn-primary from styles.css) */ /* full-width primary button (extends .btn / .btn-primary from styles.css) */
.btn-block { width: 100%; justify-content: center; height: 40px; font-size: var(--fz); } .btn-block { width: 100%; justify-content: center; height: 40px; font-size: var(--fz); }
@@ -138,74 +121,6 @@ body:has(#forgot:target) #login { display: none; }
.auth-aside a { color: var(--accent); } .auth-aside a { color: var(--accent); }
.auth-aside a:hover { text-decoration: underline; } .auth-aside a:hover { text-decoration: underline; }
/* legal footnote */
.auth-foot { margin: 0; text-align: center; font-size: var(--fz-xs); color: var(--text-faint); }
.auth-foot a { color: var(--text-muted); text-decoration: underline; }
/* =============================================================
Template preview controls (delete this block + its markup for
production). Drives theme + SSO on/off with zero JS.
============================================================= */
.tpl-controls {
position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%);
z-index: 50;
display: flex; align-items: center; gap: 12px;
padding: 6px 8px 6px 14px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 99px; box-shadow: 0 6px 22px rgba(0,0,0,.12);
}
.tpl-label { font-size: var(--fz-xs); color: var(--text-faint);
text-transform: uppercase; letter-spacing: .05em; font-weight: 600; }
.tpl-sep { width: 1px; height: 20px; background: var(--border); }
.tpl-controls .theme-switch { width: 156px; }
/* on/off switch */
.tpl-toggle { display: inline-flex; align-items: center; gap: 8px;
font-size: var(--fz-sm); color: var(--text-muted); cursor: pointer; }
.tpl-toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
.tpl-track { width: 32px; height: 18px; border-radius: 99px;
background: var(--border-2); position: relative; transition: background .15s ease; }
.tpl-track::after { content: ""; position: absolute; top: 2px; left: 2px;
width: 14px; height: 14px; border-radius: 50%; background: #fff;
box-shadow: 0 1px 2px rgba(0,0,0,.3); transition: transform .15s ease; }
.tpl-toggle:has(input:checked) .tpl-track { background: var(--accent); }
.tpl-toggle:has(input:checked) .tpl-track::after { transform: translateX(14px); }
.tpl-toggle:has(input:focus-visible) .tpl-track {
outline: 2px solid var(--focus); outline-offset: 2px; }
@media (max-width: 520px) {
.tpl-label { display: none; }
.tpl-controls { gap: 10px; padding: 6px 10px; }
}
/* =============================================================
Forgot-password feedback (Post/Redirect/Get, no JS).
The form POSTs to its own URL; on the redirect-GET the server
re-renders #forgot with a state class so the matching alert shows:
<section id="forgot" class="auth-view state-sent"> … </section>
(state-sent | state-error). No extra view is added.
The preview radios below demonstrate the same states live.
============================================================= */
.forgot-alert { display: none; }
#forgot.state-sent .forgot-alert.is-sent,
body:has(#fstate-sent:checked) #forgot .forgot-alert.is-sent { display: flex; }
#forgot.state-error .forgot-alert.is-error,
body:has(#fstate-error:checked) #forgot .forgot-alert.is-error { display: flex; }
/* the Forgot-state control only appears while the forgot view is open */
.tpl-forgot { display: none; }
body:has(#forgot:target) .tpl-forgot { display: flex; }
.tpl-controls .segmented { padding: 1px; }
.tpl-controls .segmented label { padding: 3px 8px; }
/* =============================================================
Field-level errors (e.g. "email already used"), server-driven.
The server re-renders the field's form with a state class and
adds aria-invalid="true" to the input:
<section id="register" class="auth-view state-email-taken"> … </section>
Reusable: drop a <p class="field-error"> under any field and gate
it on whatever state your server sets.
============================================================= */
.field-error { .field-error {
display: none; align-items: center; gap: 6px; margin: 0; display: none; align-items: center; gap: 6px; margin: 0;
font-size: var(--fz-xs); color: var(--neg); 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.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 > .ico { flex: 0 0 auto; color: var(--neg); }
.field-error a { color: var(--neg); font-weight: 600; text-decoration: underline; } .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; }
+26 -16
View File
@@ -28,18 +28,18 @@
--l-border:#e5e5e8; --l-border-2:#d6d6da; --l-border:#e5e5e8; --l-border-2:#d6d6da;
--l-text:#18181b; --l-text-muted:#6b6b73; --l-text-faint:#9a9aa2; --l-text:#18181b; --l-text-muted:#6b6b73; --l-text-faint:#9a9aa2;
--l-accent:#3669ac; --l-accent-bg:#e5f3ff; --l-accent:#3669ac; --l-accent-bg:#e5f3ff;
--l-accent-bd:#a7c7f2; --l-focus:#2a68b7; --l-accent-bd:#a7c7f2; --l-focus:#2a68b7; --l-on-accent:#ffffff;
--l-pos:#258343; --l-pos-bg:#e1f5e4; --l-pos-bd:#a8d5b0; --l-pos:#1f7a3d; --l-pos-bg:#e1f5e4; --l-pos-bd:#a8d5b0;
--l-neg:#c53637; --l-neg-bg:#ffebe8; --l-neg-bd:#febab4; --l-neg:#c53637; --l-neg-bg:#ffebe8; --l-neg-bd:#febab4;
--l-warn:#9a6500; --l-warn-bg:#fef0d4; --l-warn-bd:#e3c78e; --l-warn:#8f5d00; --l-warn-bg:#fef0d4; --l-warn-bd:#e3c78e;
--l-info:#2274af;--l-info-bg:#e2f5ff;--l-info-bd:#a2cdf2; --l-info:#1d6aa5;--l-info-bg:#e2f5ff;--l-info-bd:#a2cdf2;
/* ---- raw DARK palette ---- */ /* ---- raw DARK palette ---- */
--d-bg:#0d0d0f; --d-surface:#161619; --d-surface-2:#1d1d22; --d-surface-3:#1a1a1e; --d-bg:#0d0d0f; --d-surface:#161619; --d-surface-2:#1d1d22; --d-surface-3:#1a1a1e;
--d-border:#2a2a31; --d-border-2:#3a3a43; --d-border:#2a2a31; --d-border-2:#3a3a43;
--d-text:#ededf0; --d-text-muted:#9b9ba6; --d-text-faint:#6a6a74; --d-text:#ededf0; --d-text-muted:#9b9ba6; --d-text-faint:#6a6a74;
--d-accent:#6ba0e8; --d-accent-bg:#1d3351; --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-pos:#62c37a; --d-pos-bg:#1a3520; --d-pos-bd:#376040;
--d-neg:#ff7e76; --d-neg-bg:#4b1d1b; --d-neg-bd:#8a423d; --d-neg:#ff7e76; --d-neg-bg:#4b1d1b; --d-neg-bd:#8a423d;
--d-warn:#e4b750; --d-warn-bg:#3e2d10; --d-warn-bd:#72592c; --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); --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); --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); --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); --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); --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); --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); --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); --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); --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); --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); --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); --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); --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); --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); --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); --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); --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); --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); --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); --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); --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); --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); --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); --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 { .brand-mark {
width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto; width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto;
background: var(--accent); 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-logo { width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto; object-fit: contain; }
.brand-name { font-weight: 650; letter-spacing: -.01em; } .brand-name { font-weight: 650; letter-spacing: -.01em; }
.brand-sub { color: var(--text-faint); font-size: var(--fz-xs); margin-left: auto; } .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 <a href> → navigates on click. • CLICKABLE = label is <a href> → navigates on click.
STATIC = label is <span> → not a navigation target. STATIC = label is <span> → not a navigation target.
So "header" and "clickable" are independent: any combination works. So "header" and "clickable" are independent: any combination works.
Children sit in a sibling <ul class="nav-children">, revealed by Children sit in a sibling <ul class="nav-children">. Where :has() works, a
:has() on THIS row's own <details open> (scoped with > so a node branch is folded unless THIS row's own <details open> (scoped with > so a node
only ever controls its own subtree — infinite depth, no JS). only ever controls its own subtree — infinite depth, no JS). Without :has()
every branch stays open, since nothing could reopen one.
============================================================ */ ============================================================ */
.nav-row { display: flex; align-items: center; gap: 2px; } .nav-row { display: flex; align-items: center; gap: 2px; }
@@ -237,8 +238,10 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.nav-self[aria-current="page"] .ico { color: var(--accent); } .nav-self[aria-current="page"] .ico { color: var(--accent); }
/* children + infinite indentation with a guide line per level */ /* children + infinite indentation with a guide line per level */
@supports selector(:has(a)) {
.nav-node > .nav-children { display: none; } .nav-node > .nav-children { display: none; }
.nav-node:has(> .nav-row > .nav-disc[open]) > .nav-children { display: block; } .nav-node:has(> .nav-row > .nav-disc[open]) > .nav-children { display: block; }
}
.nav-children { padding-left: 22px; position: relative; } .nav-children { padding-left: 22px; position: relative; }
.nav-children::before { .nav-children::before {
content: ""; position: absolute; left: 10px; top: 2px; bottom: 2px; content: ""; position: absolute; left: 10px; top: 2px; bottom: 2px;
@@ -281,6 +284,13 @@ span.nav-self { cursor: default; } /* static / non-clickable */
outline: 2px solid var(--focus); outline-offset: 1px; outline: 2px solid var(--focus); outline-offset: 1px;
} }
/* Without :has() every branch is open and every theme rule is inert: neither control does anything.
After the base rules, since @supports adds no specificity. */
@supports not selector(:has(a)) {
.nav-disc { visibility: hidden; } /* keeps its width, so the labels stay aligned */
.theme-switch { display: none; }
}
.footer-actions { display: flex; align-items: center; gap: 4px; } .footer-actions { display: flex; align-items: center; gap: 4px; }
.profile { .profile {
display: flex; align-items: center; gap: 9px; flex: 1 1 auto; display: flex; align-items: center; gap: 9px; flex: 1 1 auto;
@@ -311,7 +321,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
} }
.btn:hover { background: var(--surface-2); } .btn:hover { background: var(--surface-2); }
.btn-primary { background: var(--accent); border-color: var(--accent); .btn-primary { background: var(--accent); border-color: var(--accent);
color: #fff; font-weight: 550; } color: var(--on-accent); font-weight: 550; }
.btn-primary:hover { filter: brightness(1.06); } .btn-primary:hover { filter: brightness(1.06); }
.btn-ghost { background: transparent; border-color: transparent; } .btn-ghost { background: transparent; border-color: transparent; }
.btn-ghost:hover { background: var(--surface-2); } .btn-ghost:hover { background: var(--surface-2); }
@@ -665,7 +675,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
} }
.page-btn:hover { background: var(--surface-2); color: var(--text); } .page-btn:hover { background: var(--surface-2); color: var(--text); }
.page-btn[aria-current="page"] { background: var(--accent); border-color: var(--accent); .page-btn[aria-current="page"] { background: var(--accent); border-color: var(--accent);
color: #fff; font-weight: 600; } color: var(--on-accent); font-weight: 600; }
.page-btn:disabled { opacity: .4; cursor: not-allowed; } .page-btn:disabled { opacity: .4; cursor: not-allowed; }
/* ---------- 11. RESPONSIVE / HAMBURGER --------------------- */ /* ---------- 11. RESPONSIVE / HAMBURGER --------------------- */
+38
View File
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
const css = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "public", "css", "styles.css"), "utf8");
const token = (name: string): string => {
const hex = new RegExp(`--${name}:\\s*(#[0-9a-f]{6})\\b`, "i").exec(css)?.[1];
assert.ok(hex, `--${name} is not a six-digit hex colour`);
return hex;
};
const luminance = (hex: string): number => {
const [r = 0, g = 0, b = 0] = [1, 3, 5].map((i) => {
const c = Number.parseInt(hex.slice(i, i + 2), 16) / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const contrast = (a: string, b: string): number => {
const [hi = 0, lo = 0] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
// WCAG AA for normal text.
for (const theme of ["l", "d"]) {
for (const tone of ["pos", "neg", "warn", "info"]) {
test(`${tone} badge text reaches 4.5:1 in ${theme === "l" ? "light" : "dark"} mode`, () => {
assert.ok(contrast(token(`${theme}-${tone}`), token(`${theme}-${tone}-bg`)) >= 4.5);
});
}
test(`text on the accent reaches 4.5:1 in ${theme === "l" ? "light" : "dark"} mode`, () => {
assert.ok(contrast(token(`${theme}-on-accent`), token(`${theme}-accent`)) >= 4.5);
});
}
+31
View File
@@ -13,3 +13,34 @@ for (const file of readdirSync(cssDir).filter((f) => f.endsWith(".css"))) {
assert.ok(!/oklch\(/i.test(readFileSync(join(cssDir, file), "utf8"))); assert.ok(!/oklch\(/i.test(readFileSync(join(cssDir, file), "utf8")));
}); });
} }
const stylesheet = readFileSync(join(cssDir, "styles.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
// 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
// branch there, so the fold may only exist where `:has()` works.
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 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");
});
// `@supports` adds no specificity, so the hiding rule has to come after the base `.theme-switch` rule.
test("styles.css hides the theme switch and the nav toggles where :has() is missing, after their base rules", () => {
const guard = stylesheet.indexOf("@supports not selector(:has(a)) {");
assert.ok(guard >= 0, "no @supports not selector(:has(a)) block");
const base = stylesheet.search(/^\.theme-switch\s*\{/m);
assert.ok(base >= 0 && base < guard, "the guard precedes the base .theme-switch rule");
const block = stylesheet.slice(guard, stylesheet.indexOf("\n}", guard));
assert.match(block, /\.theme-switch\s*\{\s*display:\s*none/);
assert.match(block, /\.nav-disc\s*\{\s*visibility:\s*hidden/);
});
+17 -25
View File
@@ -6,23 +6,21 @@
`Bar = 1` `Bar = 1`
`Next ID = 36` `Next ID = 38`
## Items ## Items
| 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 |
|---|---|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|---|---|
| 29 | 0.4.21 | | defect | **Show the whole nav tree on an engine without `:has()`, and fold it only where `:has()` works.** | 2 | 2 | 2 | 4 | 3 | 4 by 6 | 26.9 | | 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 |
| 35 | 0.4.21 | | defect | **Raise the contrast of the dark-mode primary button's white text and of the light-mode status badges' text to 4.5:1.** | 1 | 2 | 1 | 2 | 2 | 6 by 3 | 18.0 | | 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 |
| 31 | 0.4.21 | | | **Delete the design-preview rules in `public/css/auth.css` that no view uses.** | 1 | 1 | 1 | 1 | 2 | code health by 2, 8 by 1 | 13.3 |
| 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 |
| 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 | | 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 | | 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 | | 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 |
| 32 | 0.4.21 | 29 | | **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 | | 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 |
| 27 | 0.4.21 | | | **Inset the scheduling example's lead text, shift count, alerts and call-to-action by `--pad-x`, like the bars around them.** | 1 | 2 | 1 | 2 | 1 | 9 by 2, 2 by 1 | 4.8 |
| 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 | | 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 | | 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 |
| 7 | 0.4.21 | | | **Confirm a successful permission save, and report a partial failure instead of hiding it.** | 2 | 2 | 2 | 4 | 4 | 2 by 4 | 2.8 | | 7 | 0.4.21 | | | **Confirm a successful permission save, and report a partial failure instead of hiding it.** | 2 | 2 | 2 | 4 | 4 | 2 by 4 | 2.8 |
@@ -42,23 +40,22 @@
## Details ## Details
### 29. Show the whole nav tree on an engine without `:has()`, and fold it only where `:has()` works. ### 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. `public/css/styles.css` hides README → "Two browser floors": Chromium 87 must reach every page, and `:has()` may only decorate.
`.nav-children` and only a `:has()` rule reveals them, so below Chromium 105 (webOS TV 22 and 23) In `public/css/styles.css`, `@media (max-width: 860px)` moves `.sidebar` off-canvas with
no nested entry can be reached. Hide the children only inside `@supports selector(:has(a))`. The `transform: translateX(-100%)`, and only `body:has(#nav-toggle:checked)` brings it and `.scrim`
theme switch is inert there too, since every theme rule is a `:has()`: hide it under the same guard. 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`.
### 35. Raise the contrast of the dark-mode primary button's white text and of the light-mode status badges' text to 4.5:1. ### 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()`.
Measured in `public/css/styles.css`: white text on the dark-mode primary button (`--d-accent` The header (lines 4-6) says the tokens use `light-dark()`, but the TOKENS comment below it says the
`#6ba0e8`) is 2.69:1, and the text of the light-mode pos, warn and info badges on their backgrounds 4.17, 4.40 and 4.48:1. WCAG AA stylesheet avoids `light-dark()` and `color-scheme` for app colours, because engines resolve them
needs 4.5:1 for normal text. 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.
### 31. Delete the design-preview rules in `public/css/auth.css` that no view uses.
`.auth-view`, the `:target` screen switch, `.tpl-*`, the `fstate`/`rstate` radios and the
forgot/register alerts match nothing in `views/` or `examples/`.
### 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.
@@ -86,11 +83,6 @@ Nothing checks README → "Two browser floors", and no E2E engine is old enough
Lint every stylesheet, the examples' included, against Chromium 87, Safari 15 and Firefox 115, and Lint every stylesheet, the examples' included, against Chromium 87, Safari 15 and Firefox 115, and
allow a feature only inside an `@supports` that guards it. allow a feature only inside an `@supports` that guards it.
### 27. Inset the scheduling example's lead text, shift count, alerts and call-to-action by `--pad-x`, like the bars around them.
They sit flush against the content pane's edge while the filter bar, table and pager sit 12px in.
The fix belongs in `examples/plugins/scheduling/public/scheduling.css`; check each page in a browser.
### 20. Redirect an anonymous hit on a session-needing flow page to sign-in, instead of a 500. ### 20. Redirect an anonymous hit on a session-needing flow page to sign-in, instead of a 500.
`flowPage` (`src/auth/routes.ts`) rethrows a 401, which Kratos answers to a settings-flow init with no `flowPage` (`src/auth/routes.ts`) rethrows a 401, which Kratos answers to a settings-flow init with no
+2 -2
View File
@@ -1,8 +1,8 @@
<%# <%#
Recursive nav tree. Each node: { label, href?, icon?, count?, current?, open?, children? }. Recursive nav tree. Each node: { label, href?, icon?, count?, current?, open?, children? }.
Shape is orthogonal: header (children → disclosure toggle + nested <ul>) vs leaf (spacer), Shape is orthogonal: header (children → disclosure toggle + nested <ul>) vs leaf (spacer),
and clickable (href → <a>) vs static (<span>). The CSS reveals children only when the and clickable (href → <a>) vs static (<span>). Where :has() works, the CSS reveals children
sibling .nav-disc is [open]. `root` picks the outer class. only when the sibling .nav-disc is [open]; elsewhere it shows them all. `root` picks the outer class.
%><% %><%
const nodes = locals.nodes || []; const nodes = locals.nodes || [];
const root = locals.root !== false; const root = locals.root !== false;