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()` ### 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 On an engine without `:has()` (Chromium below 105, such as webOS TV 22 and 23, and Firefox below 121)
it. Now the tree folds only where `:has()` works and stays fully open elsewhere. The theme switch, every nested nav entry was hidden with nothing to open it. Now the tree folds only where `:has()`
which also needs `:has()`, is hidden there. 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` 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 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"); 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 }) => { 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,7 +4,7 @@
.scheduling-page .table-wrap { .scheduling-page .table-wrap {
margin-top: var(--space-3, 0.75rem); 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 > p,
.scheduling-page > .alert, .scheduling-page > .alert,
.scheduling-page > .btn { .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 { .auth-stage {
min-height: 100dvh; min-height: 100dvh;
display: grid; place-items: center; display: grid; place-items: center;
@@ -126,7 +121,6 @@
.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; }
/* field-level error, shown under a field the server marked .has-error */
.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);
+11 -5
View File
@@ -191,9 +191,9 @@ 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">. Where :has() works, it Children sit in a sibling <ul class="nav-children">. Where :has() works, a
folds them unless 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); elsewhere only ever controls its own subtree — infinite depth, no JS). Without :has()
every branch stays open, since nothing could reopen one. 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; }
@@ -262,8 +262,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
padding: 8px; display: flex; flex-direction: column; gap: 6px; 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 */ /* theme segmented control */
@supports not selector(:has(a)) { .theme-switch { display: none; } }
.theme-switch { .theme-switch {
display: grid; grid-template-columns: repeat(3, 1fr); display: grid; grid-template-columns: repeat(3, 1fr);
gap: 2px; padding: 2px; gap: 2px; padding: 2px;
@@ -285,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;
-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); 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, ""); 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 text is all there is to check: Playwright's engines all support `:has()`, so no E2E sees the
// the engine can undo it. // stylesheet an older engine gets.
test("styles.css hides .nav-children only inside @supports selector(:has(a))", () => { // Chromium below 105 and Firefox below 121 have no `:has()`, and nothing else reveals a folded
const guard = stylesheet.indexOf("@supports selector(:has(a))"); // branch there, so the fold may only exist where `:has()` works.
assert.ok(guard >= 0, "no @supports selector(:has(a)) guard"); test("styles.css folds .nav-children only inside @supports selector(:has(a))", () => {
const hide = /\.nav-node\s*>\s*\.nav-children\s*\{\s*display:\s*none/.exec(stylesheet); const open = stylesheet.indexOf("@supports selector(:has(a)) {");
assert.ok(hide && hide.index > guard, "the fold rule is missing or precedes the guard"); assert.ok(open >= 0, "no @supports selector(:has(a)) block");
const between = stylesheet.slice(guard, hide.index); let depth = 0;
assert.equal(between.split("{").length - between.split("}").length, 1, "the guard closes before the fold rule"); 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", () => { // `@supports` adds no specificity, so the hiding rule has to come after the base `.theme-switch` rule.
assert.match(stylesheet, /@supports not selector\(:has\(a\)\)\s*\{\s*\.theme-switch\s*\{\s*display:\s*none/); 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? }. 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;