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:
+5
-4
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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