Show the whole nav tree without :has(), raise text contrast to 4.5:1, drop unused auth rules #182
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", '<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 }) => {
|
||||
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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
min-height: 100dvh;
|
||||
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 .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 */
|
||||
.auth-card {
|
||||
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); }
|
||||
|
||||
.remember { margin-top: 2px; }
|
||||
|
||||
/* full-width primary button (extends .btn / .btn-primary from styles.css) */
|
||||
.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: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 {
|
||||
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; }
|
||||
|
||||
+26
-16
@@ -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 <a href> → navigates on click.
|
||||
STATIC = label is <span> → not a navigation target.
|
||||
So "header" and "clickable" are independent: any combination works.
|
||||
Children sit in a sibling <ul class="nav-children">, revealed by
|
||||
:has() on THIS row's own <details open> (scoped with > so a node
|
||||
only ever controls its own subtree — infinite depth, no JS).
|
||||
Children sit in a sibling <ul class="nav-children">. Where :has() works, a
|
||||
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). Without :has()
|
||||
every branch stays open, since nothing could reopen one.
|
||||
============================================================ */
|
||||
.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); }
|
||||
|
||||
/* children + infinite indentation with a guide line per level */
|
||||
@supports selector(:has(a)) {
|
||||
.nav-node > .nav-children { display: none; }
|
||||
.nav-node:has(> .nav-row > .nav-disc[open]) > .nav-children { display: block; }
|
||||
}
|
||||
.nav-children { padding-left: 22px; position: relative; }
|
||||
.nav-children::before {
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
.profile {
|
||||
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-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-ghost { background: transparent; border-color: transparent; }
|
||||
.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[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; }
|
||||
|
||||
/* ---------- 11. RESPONSIVE / HAMBURGER --------------------- */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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")));
|
||||
});
|
||||
}
|
||||
|
||||
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/);
|
||||
});
|
||||
|
||||
@@ -6,23 +6,21 @@
|
||||
|
||||
`Bar = 1`
|
||||
|
||||
`Next ID = 36`
|
||||
`Next ID = 38`
|
||||
|
||||
## Items
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
### 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
|
||||
`.nav-children` and only a `:has()` rule reveals them, so below Chromium 105 (webOS TV 22 and 23)
|
||||
no nested entry can be reached. Hide the children only inside `@supports selector(:has(a))`. The
|
||||
theme switch is inert there too, since every theme rule is a `:has()`: hide it under the same guard.
|
||||
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`.
|
||||
|
||||
### 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`
|
||||
`#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
|
||||
needs 4.5:1 for normal text.
|
||||
|
||||
### 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/`.
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
`flowPage` (`src/auth/routes.ts`) rethrows a 401, which Kratos answers to a settings-flow init with no
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<%#
|
||||
Recursive nav tree. Each node: { label, href?, icon?, count?, current?, open?, children? }.
|
||||
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
|
||||
sibling .nav-disc is [open]. `root` picks the outer class.
|
||||
and clickable (href → <a>) vs static (<span>). Where :has() works, the CSS reveals children
|
||||
only when the sibling .nav-disc is [open]; elsewhere it shows them all. `root` picks the outer class.
|
||||
%><%
|
||||
const nodes = locals.nodes || [];
|
||||
const root = locals.root !== false;
|
||||
|
||||
Reference in New Issue
Block a user