Answer stability round 1: 1 🔴, 6 🟡 (0 misread); hide the theme switch after its base rule, hide inert nav toggles, test the rendered result

This commit is contained in:
2026-10-31 01:32:26 +01:00
parent ac52abd330
commit a190166421
8 changed files with 82 additions and 34 deletions
+5 -4
View File
@@ -8,11 +8,12 @@ at boot. Entries start at 0.3.0.
### The whole nav tree shows on engines without `:has()`
On Chromium below 105 (webOS TV 22 and 23) every nested nav entry was hidden with nothing to open
it. Now the tree folds only where `:has()` works and stays fully open elsewhere. The theme switch,
which also needs `:has()`, is hidden there.
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.
### Text meets 4.5:1 contrast
### 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
+39
View File
@@ -174,6 +174,45 @@ 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 button, so a theme block that drops --on-accent fails here.
for (const colorScheme of ["light", "dark"] as const) {
test(`the primary button's text reaches 4.5:1 in ${colorScheme} mode`, async ({ page, context }) => {
await context.clearCookies();
await page.emulateMedia({ colorScheme });
await page.goto("/");
const ratio = await page.locator(".landing .btn-primary").first().evaluate((el) => {
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);
});
expect(ratio).toBeGreaterThanOrEqual(4.5);
});
}
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,7 +4,7 @@
.scheduling-page .table-wrap {
margin-top: var(--space-3, 0.75rem);
}
/* Inset the elements that sit directly in the pane like the filter bar, table and pager around them. */
/* 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 {
-6
View File
@@ -1,8 +1,3 @@
/* =============================================================
Auth flow — login / register / forgot password
Built on the shared tokens in styles.css. Pure HTML/CSS.
============================================================= */
.auth-stage {
min-height: 100dvh;
display: grid; place-items: center;
@@ -126,7 +121,6 @@
.auth-aside a { color: var(--accent); }
.auth-aside a:hover { text-decoration: underline; }
/* field-level error, shown under a field the server marked .has-error */
.field-error {
display: none; align-items: center; gap: 6px; margin: 0;
font-size: var(--fz-xs); color: var(--neg);
+11 -5
View File
@@ -191,9 +191,9 @@ 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">. Where :has() works, it
folds them unless THIS row's own <details open> (scoped with > so a node
only ever controls its own subtree — infinite depth, no JS); elsewhere
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; }
@@ -262,8 +262,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
padding: 8px; display: flex; flex-direction: column; gap: 6px;
}
/* theme segmented control — every theme rule is a :has(), so without it the switch is inert */
@supports not selector(:has(a)) { .theme-switch { display: none; } }
/* theme segmented control */
.theme-switch {
display: grid; grid-template-columns: repeat(3, 1fr);
gap: 2px; padding: 2px;
@@ -285,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;
-5
View File
@@ -36,8 +36,3 @@ for (const theme of ["l", "d"]) {
assert.ok(contrast(token(`${theme}-on-accent`), token(`${theme}-accent`)) >= 4.5);
});
}
test("the primary button and the current page button use --on-accent", () => {
assert.match(css, /\.btn-primary\s*\{[^}]*color:\s*var\(--on-accent\)/);
assert.match(css, /\.page-btn\[aria-current="page"\]\s*\{[^}]*color:\s*var\(--on-accent\)/);
});
+24 -11
View File
@@ -16,17 +16,30 @@ for (const file of readdirSync(cssDir).filter((f) => f.endsWith(".css"))) {
const stylesheet = readFileSync(join(cssDir, "styles.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
// Below Chromium 105 nothing but `:has()` reveals a folded branch, so the fold may only exist where
// the engine can undo it.
test("styles.css hides .nav-children only inside @supports selector(:has(a))", () => {
const guard = stylesheet.indexOf("@supports selector(:has(a))");
assert.ok(guard >= 0, "no @supports selector(:has(a)) guard");
const hide = /\.nav-node\s*>\s*\.nav-children\s*\{\s*display:\s*none/.exec(stylesheet);
assert.ok(hide && hide.index > guard, "the fold rule is missing or precedes the guard");
const between = stylesheet.slice(guard, hide.index);
assert.equal(between.split("{").length - between.split("}").length, 1, "the guard closes before the fold rule");
// 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");
});
test("styles.css hides the theme switch where :has() is missing", () => {
assert.match(stylesheet, /@supports not selector\(:has\(a\)\)\s*\{\s*\.theme-switch\s*\{\s*display:\s*none/);
// `@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");
assert.ok(guard > stylesheet.lastIndexOf(".theme-switch {", 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/);
});
+2 -2
View File
@@ -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;