mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-08 11:48:14 +00:00
ui, plan: make the css cascade helper refuse what it cannot model
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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 `<img>` 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user