mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-16 00:10:56 +00:00
ui, plan: resolve the logo cascade in the web checks
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 <img> 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
|
||||
|
||||
@@ -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 `<h1>`; every radio group is a real `<fieldset>` with a real `<legend>` and every radio sits inside a `<label>`; a `:focus-visible` rule exists and no `outline: none` appears anywhere; the reduced-motion block turns off the 150 ms fade; the column is capped at `max-width: 560px`; the dark-scheme `display` rules for the two logos are the **last** declarations in the file for those selectors, so the toggle actually wins the cascade; forced colours distinguish the selected card by border *style*, not by colour alone; a seeded answer skips its screen and a gap is still asked; all six hashes resolve and the built shell registers a `popstate` listener, exposes `refresh()`, and never assigns markup from a string. Each of these was run against a deliberately broken input and observed to fail there (§9).
|
||||
**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 `<h1>`; every radio group is a real `<fieldset>` with a real `<legend>` and every radio sits inside a `<label>`; a `:focus-visible` rule exists and no `outline: none` appears anywhere; the reduced-motion block turns off the 150 ms fade; the column is capped at `max-width: 560px`; the cascade is resolved for both logo `<img>` elements in both schemes and exactly one is visible in each, and the dark-scheme `display` rules are the **last** declarations in the file for those selectors; no input carries a validation constraint and the shell calls `preventDefault()` on submit, so no native bubble can replace the error banner; forced colours distinguish the selected card by border *style*, not by colour alone; a seeded answer skips its screen and a gap is still asked; all six hashes resolve and the built shell registers a `popstate` listener, exposes `refresh()`, and never assigns markup from a string. Each of these was run against a deliberately broken input and observed to fail there (§9).
|
||||
|
||||
**Rendering is asserted nowhere, by anything, until the phase's browser pass.** That pass is one §9 entry covering D1's three deferred assertions and D2's own; it is not handed from step to step. Do not read a green `npm test` as evidence that the page draws.
|
||||
|
||||
@@ -1498,7 +1498,7 @@ Append here when a step contradicts this plan: the step id, what was wrong, and
|
||||
- **D8 — the step's Verify was manual and unrunnable before merge; it is now the same command sequence run locally.** "Edit a `.ts` and confirm the job fails" cannot be executed before the workflow is on the default branch. The line now specifies a clean clone of HEAD, the job's own commands in it, and both failure modes with the exact output each produces — which is what was run. What remains unverified is only what no local run can cover: that GitHub resolves `actions/checkout@v6` and `actions/setup-node@v6` and provisions node 24 on `ubuntu-latest`. The first CI run on this branch confirms that.
|
||||
- **Phase D — ONE outstanding browser pass, held here. Do not defer it step to step.** D1 deferred three assertions to D2, D2 has no browser either, and a chain of hand-offs evaporates: each step ticks, each points at the next, and nothing is ever run. So there is one list, in this entry, and a later step that cannot run a browser **appends to it** rather than writing "D3 will cover it". Everything on it needs a human at a browser, opening `/dist/dev.html` over `python3 -m http.server` rooted at `apps/simplex-badge-service/web`, or the served site once D4 exists. Nothing in the plan may claim any of it is verified until someone reports having seen it.
|
||||
- From D1: that `dev.html` **loads at all** rather than rendering an empty shell; that `../styles.css` is **applied** and not merely served with a `text/css` header, for which the colour tokens are the visible proof; and that the browser **executes the module graph across files** — `main.js` importing `./ui.js`, and `ui.js` importing `./router.js` and `./view.js`, by the relative specifiers `tsc` does not rewrite. The third is what decision 7 and D4's single-prefix asset hash both rest on. A blank or unstyled page at this pass is as likely to be D1's mechanism as D2's markup, so confirm each separately. (D1's fourth deferral, that a `file://` open fails where HTTP succeeds, is still carried untested and nothing depends on it.)
|
||||
- From D2: a keyboard-only pass through every screen, reaching and activating **every** option and the Continue button, with browser back and forward landing on the right screen — the hash-to-screen mapping is unit-tested but `pushState`/`popstate` wiring is not; at 320 px in both colour schemes, that no screen scrolls horizontally; that the selected card's 2 px accent border is visible in both schemes; that the `:focus-visible` ring is visible against both backgrounds, **including the ring the visually hidden radio projects onto its card** — the one place where hiding an input could silently cost the focus indicator; that the 150 ms fade runs, and does not under `prefers-reduced-motion: reduce`; that selection stays distinguishable under forced colours; and that submitting a question unanswered shows the error banner rather than a native validation bubble.
|
||||
- From D2: a keyboard-only pass through every screen, reaching and activating **every** option and the Continue button, with browser back and forward landing on the right screen — the hash-to-screen mapping is unit-tested but `pushState`/`popstate` wiring is not; at 320 px in both colour schemes, that no screen scrolls horizontally; that the selected card's 2 px accent border is visible in both schemes; that the `:focus-visible` ring is visible against both backgrounds, **including the ring the visually hidden radio projects onto its card** — the one place where hiding an input could silently cost the focus indicator; that the 150 ms fade runs, and does not under `prefers-reduced-motion: reduce`; that selection stays distinguishable under forced colours; and that submitting a question unanswered **shows the error banner** — the other half of that claim, that no *native validation bubble* can appear instead, is statically decidable and has been moved off this list: it needs a constraint attribute to fail and a submit that reaches the browser's default handling, and `static.test.mjs` asserts that no input carries `required`, `pattern`, `min`, `max`, `minlength` or `maxlength` and that the built shell calls `preventDefault()` on submit. Only "the banner is visible and announced" still needs eyes.
|
||||
- **Before adding anything to this list, check whether it is statically decidable, and if it is, check it instead.** "Each scheme shows its own logo" was on this list and has been struck from it: it is not a rendering question but a stylesheet *ordering* question, and it shipped broken precisely because the list absorbed it. All four logo selectors are specificity (0,1,0), a conditional group rule adds none and does not reorder its contents, so the dark-scheme rules won nothing by being in a media query — they sat above the defaults they had to override, lost the cascade, and dark mode rendered the `#023789` logo on the `#000832` background with no error anywhere. `static.test.mjs` now asserts that the dark-scheme `display` declarations for `.logo--light`/`.logo--dark` are the **last** ones in the file for those selectors, and the fix moved that block to the end of the stylesheet. A list that absorbs statically-checkable items stops being a way to hold them and becomes a way to defer them.
|
||||
- **D2 — the shell is four modules, not the two the Files list named, and there is now a `web/test/` directory.** The step named `src/{ui.ts,main.ts}`. Splitting out `router.ts` (the pure hash-to-screen mapping) and `view.ts` (each screen as a pure element tree, with no DOM call in it) is what makes the entry above a short list instead of the whole step: with the pure half separated, "every screen has exactly one `<h1>`", "every radio is inside a `<label>`" and "all six hashes resolve" are assertions a machine can run, and only *rendering* needs a human. `ui.ts` is the only module that touches the DOM and stays unasserted. The tests are `node:test` and `node:assert`, **built-in modules**, run by a new `npm test` script: nothing was added to `package.json`'s dependencies or to the lockfile, so decision 7's one-devDependency cap is intact. They import from `dist/`, not `src/`, so they assert on the bytes a browser is served. `test/css.mjs` is a 60-line reader for the stylesheet — a CSS parser is a dependency this project may not have, and grepping raw text cannot tell a declaration inside the dark media query from one outside it, which is the exact distinction the light-mode trap turns on. **Files** corrected; `package.json` added to it.
|
||||
- **D2 — the Verify line was a browser pass, which no CI and no agent environment can run; it is now a static suite that was proved able to fail.** Rule 4 requires the Verify line to be run before the step is ticked, so a line that cannot be run anywhere but on a human's screen makes the step unfalsifiable. The line now names the assertions listed in the step, and the browser half moved into the phase entry above. Being able to *fail* is the point: fourteen mutations were applied one at a time and the suite caught each — a colour token moved into the dark block only, the accent off by one digit, a screen's `<h1>` removed, a second `<h1>` added, `<fieldset>` replaced by `<div>`, the `<label>` wrapper removed, the `popstate` listener deleted, a hash dropped from the route set, a blanket `*:focus { outline: none }` added, the reduced-motion block removed, `max-width` moved off 560px, a token written into a CSS rule, a Google Fonts `@import` added, and a logo token dropped from `index.html`. A check nobody has watched fail is not a check.
|
||||
@@ -1507,6 +1507,7 @@ Append here when a step contradicts this plan: the step id, what was wrong, and
|
||||
- **D2 — a scheme-conditional rule must come after the default it overrides, and `styles.css` says so at the site.** This is the defect struck from the browser list above, stated as the rule it leaves behind, because D3, D7 and E5 all add rules to this file. `@media` is a conditional *group* rule: it adds no specificity and does not reorder its contents, so a declaration inside one cascades at its position in the file like any other. Two declarations of equal specificity are decided by order of appearance, and every selector in this stylesheet is a single class or `:root`. The token block can sit anywhere, because nothing else declares those custom properties; a scheme-conditional *layout* rule cannot. The dark logo block is therefore last in the file, with a comment saying it must stay there, and a test enforces it. **Do not assume a media query wins.**
|
||||
- **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.
|
||||
|
||||
## 10. End-to-end verification
|
||||
|
||||
|
||||
Reference in New Issue
Block a user