From c97f27ebf16187e1b82029c51fb8159f41b443a0 Mon Sep 17 00:00:00 2001 From: shum Date: Thu, 27 Aug 2026 03:17:09 +0000 Subject: [PATCH] ui, plan: make the css cascade helper refuse what it cannot model --- apps/simplex-badge-service/web/test/css.mjs | 98 ++++++++++++++----- .../2026-08-21-badges-web-checkout.md | 1 + 2 files changed, 77 insertions(+), 22 deletions(-) diff --git a/apps/simplex-badge-service/web/test/css.mjs b/apps/simplex-badge-service/web/test/css.mjs index 158583cd08..7d5b71af4e 100644 --- a/apps/simplex-badge-service/web/test/css.mjs +++ b/apps/simplex-badge-service/web/test/css.mjs @@ -4,7 +4,10 @@ // blocks and custom properties — because the alternative is a parser // dependency and this project is capped at one (decision 7). Anything it // cannot answer, it answers by throwing rather than by returning nothing: a -// check that silently finds no rules is a check that cannot fail. +// check that silently finds no rules is a check that cannot fail, and a +// cascade resolver that silently ignores what it cannot read is worse still, +// because it returns a confident wrong answer. That guarantee is enforced, +// not documented: see `effectiveValue`. // // Rules come back in document order with the at-rules enclosing them, because // two of the questions asked here are ordering questions. A conditional group @@ -100,15 +103,33 @@ export function declaration(body, property) { // 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. +// +// Every construct below that this resolver does not model makes it THROW. That +// is the whole point of the section: a partial cascade that quietly drops what +// it cannot read returns a confident wrong answer, which is worse than the +// narrow check it replaced. `!important` is the case that proved it — an early +// `.logo--dark { display: block !important }` outranks every later plain +// declaration in a browser, and an order-and-specificity resolver reports the +// opposite while every test stays green. -/** 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 +/** The conditions this resolver knows how to evaluate. Anything else throws. */ +const MODELLED_MEDIA = [ + {condition: "prefers-color-scheme: dark", holds: (env) => env.scheme === "dark"}, + {condition: "prefers-color-scheme: light", holds: (env) => env.scheme === "light"}, + // 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. + {condition: "prefers-reduced-motion: reduce", holds: () => false}, + {condition: "forced-colors: active", holds: () => false}, +] + +/** True when an at-rule prelude holds in `env`. Throws on anything unmodelled. */ +function atRuleHolds(prelude, env) { + if (!prelude.startsWith("@media")) { + throw new Error(`${prelude.split("{")[0].trim()} is not modelled by effectiveValue; it would change which rule wins. Teach it or remove the rule.`) + } + const known = MODELLED_MEDIA.find((m) => prelude.includes(m.condition)) + if (!known) throw new Error(`media condition not modelled by effectiveValue: ${prelude.trim()}`) + return known.holds(env) } /** Specificity of a simple selector, as [ids, classes, types]. Null if not simple. */ @@ -121,13 +142,7 @@ function specificity(selector) { 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. - */ +/** True when a simple selector list matches `element` — `{tag, classes}`. */ function selectorMatches(selectorList, element) { return selectorList.split(",").some((part) => { const s = part.trim() @@ -139,18 +154,57 @@ function selectorMatches(selectorList, element) { }) } +/** + * True when a selector list contains a part this resolver cannot read that + * could nonetheless match `element`. + * + * Combinators and pseudo-classes are not understood, and treating them as + * "does not match" is only safe when they demonstrably cannot match: a part + * naming none of the element's classes, not its tag and not `*` cannot. A + * `::pseudo-element` part styles something that is not the element at all. + */ +function unreadableAndCouldMatch(selectorList, element) { + return selectorList.split(",").some((part) => { + const s = part.trim() + if (s === "*" || specificity(s) !== null || s.includes("::")) return false + if (s.includes("*")) return true + if (new RegExp(`(^|[^\\w.-])${element.tag}([^\\w-]|$)`).test(s)) return true + return element.classes.some((c) => s.includes(`.${c}`)) + }) +} + /** * 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. + * no matching rule declares it. + * + * Applicable rules are ordered by specificity and then by document position, + * which is the whole cascade for a sheet with no `!important` and no `@layer`. + * Both are refused rather than assumed: see the checks below. (Inline styles + * and the style attribute cannot occur in a stylesheet, so they need no + * clause — the earlier docstring naming them was noise.) */ export function effectiveValue(css, element, property, env) { - const winners = allRules(css) + // Layer order is sheet-wide and reorders the cascade regardless of where the + // rules sit, so this one is global rather than per-candidate. + if (/@layer\b/.test(css)) { + throw new Error("effectiveValue does not model @layer, which reorders the cascade sheet-wide. Teach it or drop the layer.") + } + const candidates = allRules(css) .map((rule, order) => ({...rule, order})) - .filter((r) => r.at.every((a) => mediaMatches(a, env))) - .filter((r) => selectorMatches(r.selector, element)) + .filter((r) => { + if (unreadableAndCouldMatch(r.selector, element)) { + throw new Error(`effectiveValue cannot read the selector "${r.selector}", which may match ${element.tag}.${element.classes.join(".")}`) + } + return selectorMatches(r.selector, element) + }) .filter((r) => declaration(r.body, property) !== undefined) + for (const r of candidates) { + if (/!\s*important/.test(declaration(r.body, property))) { + throw new Error(`"${r.selector} { ${property}: ${declaration(r.body, property)} }" is !important, which outranks every plain declaration whatever the order. effectiveValue does not model it.`) + } + } + const winners = candidates + .filter((r) => r.at.every((a) => atRuleHolds(a, env))) .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] 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 0394417b70..38890ec133 100644 --- a/plans/badges-codes/2026-08-21-badges-web-checkout.md +++ b/plans/badges-codes/2026-08-21-badges-web-checkout.md @@ -1508,6 +1508,7 @@ Append here when a step contradicts this plan: the step id, what was wrong, and - **D2 fix round — four gaps found by review that the static suite could not see, all now covered.** Each was a hole in what the assertions asked about rather than a wrong answer to one, which is the failure mode that matters for a suite standing in for a browser. (1) The logo toggle had **zero** coverage — no test mentioned `.logo` — which is why it shipped dead. (2) `Shell` had no `refresh()`, so D3's asynchronous catalog would have had to redraw through `go()`, pushing a duplicate history entry and breaking back; it is now a member that renders in place with no history entry and no focus move. (3) `startShell` took no initial answers and started at `FIRST_SCREEN` unconditionally, so D5's prefill had nothing to seed. Both are now additive — `startShell(root, initial)` and a pure `firstUnansweredScreen(answers)` in `view.ts` — and `screenIdForHash` returns null for an empty hash rather than `FIRST_SCREEN`, because *where to start* is not a property of the hash: conflating them made the fallback re-ask the first question a prefilled URL had already answered. D5's **Files** list is therefore still correct. (4) Selection was carried by border colour alone, which `forced-colors: active` overrides, leaving selected and unselected identical while the radio is clipped to 1×1; a `forced-colors` block now also distinguishes them by border *style*, which forced colours do not override, and not by an outline — the focus ring is an outline on the same element and one would erase the other. Separately, `main.ts` failed silently when `#app` was absent, the last blank-screen path on a site whose stated rule is to have none; it now renders the banner into `document.body` and throws. - **D2 fix round — D3's and D7's Files lists were left stale by D2's module split, and are corrected.** D3 replaces the placeholder options, which live in `view.ts`, and needs `ui.ts` for `refresh()`; D7 replaces the checkout-submit branch, which lives in `ui.ts`. Both listed only the new modules they create. Correcting one's own Files list while leaving the invalidated downstream ones is how a Files list stops being trustworthy, and rule 3 has later steps assuming earlier files exist — not that they are where the plan last said they were. - **D2 fix round 2 — "which rule comes last" is not the same question as "what does the element get", and only the second one is safe to assert.** The first logo test asserted that `.logo` is `display: block` and that one unconditional `.logo--dark { display: none }` exists. Both halves were true of a stylesheet that also carried an unconditional `.logo--light { display: none }` — which shows **zero** logos in light mode — and the whole suite passed with that rule added. The fix was the *outcome*: `test/css.mjs` now resolves the cascade (`effectiveValue`) — applicable rules filtered by their media conditions, ordered by specificity and then document position — and the test asserts that in each scheme exactly one of the two `` elements resolves to a `display` other than `none`. Three mutations now fail it, one per way of getting it wrong: zero logos, the wrong logo, both logos. **A check phrased over the rules can only catch the mistakes you thought of; a check phrased over the result catches the ones you did not.** This is the third time in this step that the shape of a verification claim, rather than its content, was the defect — see also the browser-list ruling above. +- **D2 fix round 3 — the cascade resolver now refuses input it cannot model, instead of documenting a precondition and hoping.** `effectiveValue` ordered candidates by specificity and document position and had no concept of `!important`, while its docstring named "no `!important`, no inline styles, no layers" as a *precondition on the caller*. An early unconditional `.logo--dark { display: block !important }` — which in a browser shows two logos in light mode, since an important normal declaration outranks every plain one whatever the order — was resolved to `none`, and the whole suite passed. A partial resolver that silently drops what it cannot read is worse than the narrow check it replaced: the narrow one was incomplete, this one was confidently wrong. It now **throws** on `!important` in any matched declaration, on `@layer` anywhere in the sheet (layer order is sheet-wide, so that check is global rather than per-candidate), on any at-rule or media condition outside the four it models, and on a selector it cannot parse that could nonetheless match the element — a combinator or pseudo-class naming one of the element's classes, its tag, or `*`. The "inline styles" clause was dropped as vacuous: a stylesheet has none. **The rule this leaves for anyone extending `test/css.mjs`: a helper's guarantee belongs in its code, not in a comment asking callers to be careful.** The same reasoning as the `!important` case applies to any future cascade feature — refusing to answer is always available, and is never the wrong answer. ## 10. End-to-end verification