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 `