From e65a7dd7756320ce8c5eb6909642e7e3b901dd28 Mon Sep 17 00:00:00 2001 From: shum Date: Thu, 27 Aug 2026 03:08:22 +0000 Subject: [PATCH] ui, plan: resolve the logo cascade in the web checks --- apps/simplex-badge-service/web/test/css.mjs | 83 +++++++++++++++++-- .../web/test/router.test.mjs | 2 +- .../web/test/static.test.mjs | 47 +++++++++-- .../2026-08-21-badges-web-checkout.md | 5 +- 4 files changed, 116 insertions(+), 21 deletions(-) diff --git a/apps/simplex-badge-service/web/test/css.mjs b/apps/simplex-badge-service/web/test/css.mjs index 49afe53754..158583cd08 100644 --- a/apps/simplex-badge-service/web/test/css.mjs +++ b/apps/simplex-badge-service/web/test/css.mjs @@ -31,12 +31,13 @@ export function stripComments(css) { } /** - * Every style rule in the sheet, in document order, as - * `{selector, body, start, at}` — `at` being the preludes of the at-rules - * enclosing it, outermost first, and `start` the offset of its selector in the - * sheet as a whole. Keyframe blocks are skipped: `from`/`to` are not selectors. + * Every style rule in the sheet, in document order, as `{selector, body, at}` + * — `at` being the preludes of the at-rules enclosing it, outermost first. + * Position in the returned array *is* document order, which is what the + * cascade needs; no offset is carried, because nothing would read it. + * Keyframe blocks are skipped: `from`/`to` are not selectors. */ -export function allRules(css, at = [], base = 0) { +export function allRules(css, at = []) { const found = [] let i = 0 while (i < css.length) { @@ -51,11 +52,9 @@ export function allRules(css, at = [], base = 0) { i = semi + 1 continue } - if (!head.startsWith("@keyframes")) found.push(...allRules(block.body, [...at, head], base + open + 1)) + if (!head.startsWith("@keyframes")) found.push(...allRules(block.body, [...at, head])) } else { - const raw = css.slice(i, open) - const indent = raw.length - raw.trimStart().length - found.push({selector: head, body: block.body, start: base + i + indent, at}) + found.push({selector: head, body: block.body, at}) } i = block.end } @@ -93,3 +92,69 @@ export function declaration(body, property) { const m = body.match(new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;}]+)`)) return m ? m[1].trim() : undefined } + +// -- resolving the cascade -------------------------------------------------- +// +// Enough of it for the questions this stylesheet raises, and no more. Asking +// "which rule is last" only answers the cascade when you already know which +// rules match and that they are equally specific; asking for the *effective* +// value of a property on an element does not require knowing that in advance, +// which is why the logo check is written the second way. + +/** True when a media prelude holds in `env`. Any condition not modelled is false. */ +function mediaMatches(prelude, env) { + if (prelude.includes("prefers-color-scheme: dark")) return env.scheme === "dark" + if (prelude.includes("prefers-color-scheme: light")) return env.scheme === "light" + // Everything else — reduced motion, forced colours — is off in the plain + // environment these questions are asked in. Modelling one as "always true" + // would silently answer for a browser nobody was asking about. + return false +} + +/** Specificity of a simple selector, as [ids, classes, types]. Null if not simple. */ +function specificity(selector) { + const s = selector.trim() + if (s === "*") return [0, 0, 0] + if (!/^(?:[a-z][\w-]*)?(?:\.[\w-]+)*$/i.test(s) || s === "") return null + const type = /^[a-z][\w-]*/i.exec(s) + const classes = [...s.matchAll(/\.([\w-]+)/g)] + return [0, classes.length, type ? 1 : 0] +} + +/** + * True when a selector list matches `element` — `{tag, classes}`. + * + * Only class, type and universal selectors are understood. Anything else + * (combinators, pseudo-classes) is treated as not matching, which is safe + * here: no such rule in this sheet targets a logo. + */ +function selectorMatches(selectorList, element) { + return selectorList.split(",").some((part) => { + const s = part.trim() + if (s === "*") return true + if (specificity(s) === null) return false + const type = /^[a-z][\w-]*/i.exec(s) + if (type && type[0] !== element.tag) return false + return [...s.matchAll(/\.([\w-]+)/g)].every((m) => element.classes.includes(m[1])) + }) +} + +/** + * The value `property` actually takes on `element` in `env`, or undefined when + * no matching rule declares it. Applicable rules are ordered by specificity + * and then by document position, which is the cascade for a sheet with no + * `!important`, no inline styles and no layers — this one. + */ +export function effectiveValue(css, element, property, env) { + const winners = allRules(css) + .map((rule, order) => ({...rule, order})) + .filter((r) => r.at.every((a) => mediaMatches(a, env))) + .filter((r) => selectorMatches(r.selector, element)) + .filter((r) => declaration(r.body, property) !== undefined) + .sort((a, b) => { + const [sa, sb] = [specificity(a.selector.split(",")[0]), specificity(b.selector.split(",")[0])] + for (let i = 0; i < 3; i++) if (sa[i] !== sb[i]) return sa[i] - sb[i] + return a.order - b.order + }) + return winners.length ? declaration(winners[winners.length - 1].body, property) : undefined +} diff --git a/apps/simplex-badge-service/web/test/router.test.mjs b/apps/simplex-badge-service/web/test/router.test.mjs index 5b86643240..19ad1594b3 100644 --- a/apps/simplex-badge-service/web/test/router.test.mjs +++ b/apps/simplex-badge-service/web/test/router.test.mjs @@ -2,7 +2,7 @@ // with `npm test` (node's built-in runner — no dependency, decision 7). // // These run against dist/, not src/, so they test what a browser is served. -// Run `npm run build` first; `npm test` does not compile. +// `npm test` compiles first, so the suite can never pass against a stale build. import test from "node:test" import assert from "node:assert/strict" diff --git a/apps/simplex-badge-service/web/test/static.test.mjs b/apps/simplex-badge-service/web/test/static.test.mjs index 415c021037..3fdc66889a 100644 --- a/apps/simplex-badge-service/web/test/static.test.mjs +++ b/apps/simplex-badge-service/web/test/static.test.mjs @@ -4,7 +4,7 @@ // claims that anything *renders*. What it does assert is every property of the // built output that a browser would otherwise be the only witness to, and each // assertion was checked to fail when the thing it names is broken (see the -// D2 report). Run with `npm test` after `npm run build`. +// D2 report). Run with `npm test`, which compiles before it asserts. // // Assertions are against dist/ and against the two files served from web/, // never against src/: the served bytes are what a browser gets. @@ -16,7 +16,7 @@ import {fileURLToPath} from "node:url" import {SCREEN_IDS} from "../dist/router.js" import {firstUnansweredScreen, optionsOfQuestion, questionOfScreen, screenView} from "../dist/view.js" -import {allRules, customProperties, declaration, mediaRules, referencedProperties, rules, stripComments} from "./css.mjs" +import {allRules, customProperties, declaration, effectiveValue, mediaRules, referencedProperties, rules, stripComments} from "./css.mjs" const read = (rel) => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), "utf8") @@ -157,13 +157,26 @@ test("the dark-scheme logo rules are the last word on those selectors", () => { } }) -test("light mode shows exactly one logo, by default and without a media query", () => { - // The other half: with no media query matching, .logo is shown and the dark - // variant is hidden. Both must be plain top-level rules. - assert.equal(declaration(rules(css, ".logo")[0], "display"), "block") - const defaults = allRules(css).filter((r) => r.selector === ".logo--dark" && r.at.length === 0) - assert.equal(defaults.length, 1, "expected one unconditional .logo--dark rule") - assert.equal(declaration(defaults[0].body, "display"), "none") +test("exactly one logo is visible in each scheme, resolved through the cascade", () => { + // Spot-checking one side of this is not enough, and that is not a + // hypothetical: the first version of this test asserted `.logo` is block and + // that `.logo--dark` is hidden by default, and an added + // `.logo--light { display: none }` — which shows *zero* logos in light mode — + // passed it. So resolve what `display` each actually gets in each + // scheme, and assert on the outcome rather than on one rule. + const logos = { + "logo--light": {tag: "img", classes: ["logo", "logo--light"]}, + "logo--dark": {tag: "img", classes: ["logo", "logo--dark"]}, + } + for (const scheme of ["light", "dark"]) { + const visible = [] + for (const [name, element] of Object.entries(logos)) { + const display = effectiveValue(css, element, "display", {scheme}) + assert.notEqual(display, undefined, `no rule sets display on .${name} in ${scheme} mode`) + if (display !== "none") visible.push(name) + } + assert.deepEqual(visible, [scheme === "dark" ? "logo--dark" : "logo--light"], `wrong logos visible in ${scheme} mode`) + } }) test("forced colours carry the selected card by border style, not by colour alone", () => { @@ -298,6 +311,22 @@ test("the shell registers a popstate listener, so back and forward work", () => assert.match(uiJs, /addEventListener\(\s*["']popstate["']/, "no popstate listener in the built shell") }) +test("nothing can raise a native validation bubble", () => { + // The browser list holds "an unanswered question shows the banner rather + // than a native bubble". Whether the banner is *visible* needs a browser; + // whether a bubble is possible does not. It takes a constraint to fail and + // a submit that reaches the browser's default: neither exists. + for (const id of SCREEN_IDS) { + for (const {node} of findAll(screenView(id, {}), "input")) { + for (const attr of ["required", "pattern", "min", "max", "minlength", "maxlength"]) { + assert.ok(!(attr in node.attrs), `an input on screen ${id} carries ${attr}, which can raise a native bubble`) + } + } + } + assert.match(uiJs, /addEventListener\(\s*["']submit["']/, "no submit listener in the built shell") + assert.match(uiJs, /\.preventDefault\(\)/, "the submit handler must stop the browser's own handling") +}) + test("the shell can redraw in place, without a history entry", () => { // D3's catalog arrives after the first render. Without refresh() its only // way back onto the screen is go(), which pushes a duplicate entry and diff --git a/plans/badges-codes/2026-08-21-badges-web-checkout.md b/plans/badges-codes/2026-08-21-badges-web-checkout.md index 2eae1a1dfb..0394417b70 100644 --- a/plans/badges-codes/2026-08-21-badges-web-checkout.md +++ b/plans/badges-codes/2026-08-21-badges-web-checkout.md @@ -799,7 +799,7 @@ Phase D ends with a browsable, priced site wizard whose Pay button reaches a rea - A visible error banner for failed fetches, and one when `#app` itself is missing. Never a silent blank screen. - A support contact renders in the footer on every screen, from the `@@support_contact@@` token D4 substitutes (D1). It is the site's only contact channel, and D7, E5, E6 and F3 all direct users to it. -**Verify:** In `apps/simplex-badge-service/web`: `npx tsc --noEmit` clean, `npm run build`, then `npm test` (`test/router.test.mjs` and `test/static.test.mjs`), then a second `npm run build` leaving `git status --porcelain -- apps/simplex-badge-service/web/dist` empty (D8). The assertions run against the **built** output and against the two files served from `web/`, never against `src/`: every custom property used anywhere has a definition on bare `:root` and the dark block introduces none of its own; the accent is exactly `#0053D0` and `#70F0F9`; `styles.css` carries no `@@…@@` token, no `url()` and no absolute URL; every screen renders exactly one `

`; every radio group is a real `
` with a real `` and every radio sits inside a `