store: guard the card-price cascade over built output in a browser, not over globals.css source text #72

Closed
opened 2026-08-28 07:39:24 +00:00 by yuris · 1 comment
Owner

Spun out of PR #67, which shipped the #57 / #58 fixes with no automated regression guard. This issue is the guard, done in the one shape that can actually work.

Why the obvious approach fails, in detail — so nobody rebuilds it

PR #67 built a source-text guard over apps/store/app/globals.css and it went through three shapes across five review rounds, two of which were defeated by measurement on a real browser:

  1. Plain source-text assertions. Defeated three ways by its own author before review: a later same-specificity rule, !important on the ancestor, and a track-list swap. A guard for a cascade defect that cannot see !important is disqualifying.
  2. A 428-line cascade resolver — ancestor chains, right-to-left matching, specificity, @media evaluation, !important → specificity → source order. Validated at 462 comparisons against Chromium with zero disagreements, including specificity ties, shorthand-vs-longhand, inherited properties and breakpoint boundaries. Then defeated three ways by a reviewer, decisively by .browser .product-card p — a rule its modelled DOM chain never contained, so it was not mis-ranked, it was invisible.
  3. Pin-and-forbid, gathering rules by whether their selector subject could be the element, never modelling the DOM. Survived 25 attacks. Then defeated by attack 28.

Attack 28 is the one that matters, and it kills the whole category. One line of ordinary CSS nesting:

.product-grid { & .price { color: var(--ink-muted); } }

puts #57 back on all five surfaces and all eleven card prices with the suite at 97 pass / 0 fail. The same trick puts #58 back as filed — 0px name column at 320/360/375, three overlapping pairs. @property --ink { initial-value: #5a6159 } does it too, silently, because the parser recurses into a declarations-only at-rule body, finds no brace, and returns nothing.

Root cause: the parser never descended into a non-at-rule body, so a nested rule was swallowed into its parent and never became a rule at all — bypassing the pin, the token forbid clause, the breakpoint contract and the absence guard simultaneously.

And the twist that settles the design question: Next flattens that nested rule to .product-grid .price{color:var(--ink-muted)} in the shipped bundle — the exact selector an earlier round confirmed the guard does catch in flat form. So the guard's coverage was a property of how the CSS happened to be written, not of the defect class, because it read the authoring source while the browser consumes the compiled output.

That is not a missing case. It is the wrong artefact.

The rule to take from it

The defect class is a rendered property. The only sound instrument for a rendered property is rendered output.

Every author and reviewer across five rounds actually verified these fixes by building the store and reading getComputedStyle in a real browser. The guard was an attempt to approximate that in a unit test, and each approximation was defeated by something the real engine does and the model did not. A partial CSS engine will keep losing to the real one — nesting today, @scope or container queries tomorrow.

A guard that grades authoring style is worse than no guard, because the next author trusts it.

What to build

A committed verification script that asserts computed values over the built export, replacing what people have been doing by hand:

  1. Serve a schemaVersion: 3 catalog fixture, pnpm --filter garage-store build, serve apps/store/out.
  2. Prove provenance before reading anything: sitemap.xml <lastmod> must equal the fixture's generatedAt. Since #64 the catalog fetch is keyed per build, so a stale render is no longer possible — but the check is cheap and makes the output self-identifying.
  3. Drive headless Chromium and assert:
    • card .price computed color equals the in-page resolved --ink on /, /products and /services/<slug> — equality with the token, not "not muted". The defect rendered rgb(90,97,89); the fix gives rgb(23,32,24).
    • card prices are bottom-aligned across a row. This is #57's second declaration, which the issue never recorded: margin-top: auto was also losing to .product-card p { margin: 0 }. Gaps went 103.9/83.4/42.3/62.9/42.3 → 13/42.3/13/42.3/42.3.
    • the detail-page hierarchy holds: .product-detail .price 30.4px vs card 18.4px.
    • /cart at 320, 360, 375, 390, 600px, on a stocked-only line and an indent line, with a long product name: name column width non-zero, no overlapping element pairs, overflowX 0.
  4. Mutation-verify it. For each assertion, make it fail on purpose once — the discipline that caught four false-green instruments in this repo. A guard that cannot be made to fail is not testing anything. Attack 28's six variants (& .price nesting, implicit nesting, nested @media, @property, nested token redefinition, nested -webkit-text-fill-color) are the acceptance suite: every one of them must fail this guard, since every one of them passed the source-text guard.

The real obstacle, stated honestly

No CI runs on pull requests here. Both .forgejo/workflows/*.yml fire only on push: branches:[main] + workflow_dispatch. So this cannot gate a PR automatically no matter how it is written — it is a script a human or agent runs and pastes, exactly like every other verification in this repo.

That is a reason to keep it one command with readable output, and an argument for fixing the CI gap separately. It is not a reason to prefer a unit test that runs automatically and checks the wrong artefact.

Scope notes

  • Do not resurrect any part of the deleted guard. It is in the history of PR #67 (18cefd4, 623 lines) with five rounds of review findings against it. Read it to learn what breaks, not to salvage.
  • -webkit-text-fill-color deserves a case of its own: it does not compete with color, it paints over whatever color resolves to — so a ranking-based check cannot see it, and it also slips a getComputedStyle().color assertion. Assert the rendered pixel or assert the property's absence.
  • .product-type is the first instance of this cascade class, held up by !important since 6e36a27 (#4), and is not currently losing — deliberately left alone by #67 and corroborated independently by biome's noImportantStyles at globals.css:506:24. Worth a case so that changes there are noticed.

Acceptance

One command builds the store and asserts computed card-price colour, price bottom-alignment, the detail-page hierarchy, and /cart layout from 320px up, against the built export in a real browser; the output identifies which fixture produced it; every assertion has been made to fail on purpose at least once; and all six attack-28 variants fail the guard.

Spun out of PR #67, which shipped the #57 / #58 fixes with **no automated regression guard**. This issue is the guard, done in the one shape that can actually work. ## Why the obvious approach fails, in detail — so nobody rebuilds it PR #67 built a source-text guard over `apps/store/app/globals.css` and it went through **three shapes across five review rounds**, two of which were defeated by measurement on a real browser: 1. **Plain source-text assertions.** Defeated three ways by its own author before review: a later same-specificity rule, `!important` on the ancestor, and a track-list swap. A guard for a cascade defect that cannot see `!important` is disqualifying. 2. **A 428-line cascade resolver** — ancestor chains, right-to-left matching, specificity, `@media` evaluation, `!important` → specificity → source order. Validated at **462 comparisons against Chromium with zero disagreements**, including specificity ties, shorthand-vs-longhand, inherited properties and breakpoint boundaries. Then defeated three ways by a reviewer, decisively by `.browser .product-card p` — a rule its modelled DOM chain never contained, so it was not mis-ranked, it was **invisible**. 3. **Pin-and-forbid**, gathering rules by whether their **selector subject** could be the element, never modelling the DOM. Survived 25 attacks. Then defeated by **attack 28**. **Attack 28 is the one that matters, and it kills the whole category.** One line of ordinary CSS nesting: ```css .product-grid { & .price { color: var(--ink-muted); } } ``` puts #57 back on all five surfaces and all eleven card prices with the suite at **97 pass / 0 fail**. The same trick puts #58 back as filed — 0px name column at 320/360/375, three overlapping pairs. `@property --ink { initial-value: #5a6159 }` does it too, silently, because the parser recurses into a declarations-only at-rule body, finds no brace, and returns nothing. Root cause: the parser never descended into a non-at-rule body, so a nested rule was swallowed into its parent and never became a rule at all — bypassing the pin, the token forbid clause, the breakpoint contract **and** the absence guard simultaneously. **And the twist that settles the design question:** Next flattens that nested rule to `.product-grid .price{color:var(--ink-muted)}` in the shipped bundle — *the exact selector an earlier round confirmed the guard does catch in flat form.* So the guard's coverage was a property of **how the CSS happened to be written**, not of the defect class, because it read the authoring source while the browser consumes the compiled output. That is not a missing case. It is the wrong artefact. ## The rule to take from it **The defect class is a rendered property. The only sound instrument for a rendered property is rendered output.** Every author and reviewer across five rounds actually verified these fixes by building the store and reading `getComputedStyle` in a real browser. The guard was an attempt to approximate that in a unit test, and each approximation was defeated by something the real engine does and the model did not. A partial CSS engine will keep losing to the real one — nesting today, `@scope` or container queries tomorrow. A guard that grades authoring style is **worse than no guard**, because the next author trusts it. ## What to build A committed verification script that asserts computed values over the **built export**, replacing what people have been doing by hand: 1. Serve a `schemaVersion: 3` catalog fixture, `pnpm --filter garage-store build`, serve `apps/store/out`. 2. **Prove provenance before reading anything**: `sitemap.xml` `<lastmod>` must equal the fixture's `generatedAt`. Since #64 the catalog fetch is keyed per build, so a stale render is no longer possible — but the check is cheap and makes the output self-identifying. 3. Drive headless Chromium and assert: - card `.price` computed `color` **equals the in-page resolved `--ink`** on `/`, `/products` and `/services/<slug>` — equality with the token, not "not muted". The defect rendered `rgb(90,97,89)`; the fix gives `rgb(23,32,24)`. - card prices are bottom-aligned across a row. This is #57's **second** declaration, which the issue never recorded: `margin-top: auto` was also losing to `.product-card p { margin: 0 }`. Gaps went `103.9/83.4/42.3/62.9/42.3` → `13/42.3/13/42.3/42.3`. - the detail-page hierarchy holds: `.product-detail .price` **30.4px** vs card **18.4px**. - `/cart` at **320, 360, 375, 390, 600px**, on a **stocked-only** line and an indent line, with a long product name: name column width non-zero, no overlapping element pairs, `overflowX` 0. 4. **Mutation-verify it.** For each assertion, make it fail on purpose once — the discipline that caught four false-green instruments in this repo. A guard that cannot be made to fail is not testing anything. Attack 28's six variants (`& .price` nesting, implicit nesting, nested `@media`, `@property`, nested token redefinition, nested `-webkit-text-fill-color`) are the acceptance suite: **every one of them must fail this guard**, since every one of them passed the source-text guard. ## The real obstacle, stated honestly **No CI runs on pull requests here.** Both `.forgejo/workflows/*.yml` fire only on `push: branches:[main]` + `workflow_dispatch`. So this cannot gate a PR automatically no matter how it is written — it is a script a human or agent runs and pastes, exactly like every other verification in this repo. That is a reason to keep it **one command with readable output**, and an argument for fixing the CI gap separately. It is not a reason to prefer a unit test that runs automatically and checks the wrong artefact. ## Scope notes - **Do not resurrect any part of the deleted guard.** It is in the history of PR #67 (`18cefd4`, 623 lines) with five rounds of review findings against it. Read it to learn what breaks, not to salvage. - `-webkit-text-fill-color` deserves a case of its own: it does **not** compete with `color`, it paints over whatever `color` resolves to — so a ranking-based check cannot see it, and it also slips a `getComputedStyle().color` assertion. Assert the rendered pixel or assert the property's absence. - **`.product-type`** is the *first* instance of this cascade class, held up by `!important` since `6e36a27` (#4), and is **not currently losing** — deliberately left alone by #67 and corroborated independently by biome's `noImportantStyles` at `globals.css:506:24`. Worth a case so that changes there are noticed. ## Acceptance One command builds the store and asserts computed card-price colour, price bottom-alignment, the detail-page hierarchy, and `/cart` layout from 320px up, against the **built export** in a real browser; the output identifies which fixture produced it; every assertion has been made to fail on purpose at least once; and all six attack-28 variants fail the guard.
Author
Owner

Closed as stale / YAGNI. The underlying CSS cascade bugs (#57, #58) are resolved on main. Authoring a standalone headless Chromium runner for historical source-text parser attacks without CI integration adds ongoing maintenance overhead without catching real regression.

Closed as stale / YAGNI. The underlying CSS cascade bugs (#57, #58) are resolved on `main`. Authoring a standalone headless Chromium runner for historical source-text parser attacks without CI integration adds ongoing maintenance overhead without catching real regression.
yuris closed this issue 2026-09-28 20:34:59 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
yuris/garage#72
No description provided.