Skip to content

Resolve static parity through the production selector engine - #1908

Merged
chubes4 merged 4 commits into
trunkfrom
fix/static-parity-cascade-fidelity
Sep 17, 2026
Merged

chubes4 merged 4 commits into
trunkfrom
fix/static-parity-cascade-fidelity

Conversation

@chubes4

@chubes4 chubes4 commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Prerequisite for #1893, under #1898.

#1893 proposes running parity fixtures at more than one viewport. Before writing that, I measured whether it would have caught the defects it is meant to catch. It would not, and the reason is upstream of the viewport: the static parity probe cannot read the author CSS in question.

Running the shipped StaticStyleParityRunner on the #1879 reproduction, against this branch's merge base and against 8e38c3f3^ (the commit before #1879 was fixed):

=== trunk (fixed)                  === pre-#1879 (broken)
escaped  status=pass  score=1.000      escaped  status=pass  score=1.000
plain    status=fail  score=0.000      plain    status=fail  score=0.000

Identical before and after the fix. The escaped variant is the real Tailwind shape and the probe scores it a clean pass either way.

What was wrong

StaticCssCascade carried its own selector grammar, separate from the engine's. It recognised #id, .class, tag, tag.class… and combinator chains of those. Three ordinary shapes matched no element at all:

selector before after
.card.wide no match matches
.card[data-x] no match matches
.md\:hidden no match matches

The third one is why this blocks #1893. Every Tailwind variant utility is an escaped identifier, so the probe was blind to the whole utility layer of a Tailwind v4 build — the exact author CSS behind #1865 and #1879. A probe that cannot read a rule cannot report that the transform dropped it, and a declaration the author wrote was measured as absent from the source. Adding a second viewport to a probe that sees no media-conditional declarations changes nothing.

Across the 90-site corpus, ~10.6% of the 22,145 author selectors are statically resolvable shapes the old grammar silently dropped (4.9% tagless compound, 2.1% attribute, 3.6% other).

Specificity was read by a second separate grammar, so a selector could be ranked by one and matched by another: .md\:hidden scored 11, counting .md plus a phantom hidden type, where CSS says 10.

Changes

:root in the production selector matcher (4b4a3c78)

This began as a probe fix and turned out to be an engine bug. Nine projectors emit :root-scoped support CSS as a specificity device, and author stylesheets declare custom properties on bare :root, but CssSelectorMatcher rejected the selector outright — specificity() returned 0 for every one and no call site could match them.

The effect was that a :root override inside @media (prefers-color-scheme: dark) resolved as though it were the base state. Measured in Chromium on the 45-markdown-ssg-blog corpus fixture:

.nav a
  light / no-preference   rgb(68, 66, 63)     #44423f
  dark                    rgb(194, 191, 186)  #c2bfba

The engine was baking #c2bfba — the dark value — into the navigation link colour of a default render. 46-mdx-design-system carried the same leak through --halo-surface-sunken.

:root is a resting-state structural selector: it matches the document element and contributes class-level specificity, so it sits alongside :first-child and :last-child rather than with the interaction states.

Static parity resolves through the production selector engine (a541c0e6)

Deleted the private grammar and delegated to CssSelectorMatcher, which already supports all three shapes and is what the engine uses in production. :is(), :where() and :not() come along with it. Unsupported selectors fail closed, because not matching leaves a declaration missing while matching wrongly invents one the author never wrote. Net 114 lines removed.

Specificity keeps its local reading for now: it accounts for the zero_specificity metadata :where() produces, which CssSelectorMatcher::specificity() does not yet honour (see below).

Cascade layers are kept (bff0953d)

At-rules were removed textually before the rule grammar was read, which meant @layer wrappers were deleted outright. That discards the precedence the author chose: a later layer then loses to an earlier layer whenever the earlier one is more specific, and unlayered engine CSS becomes indistinguishable from layered author CSS.

This is the cascade-escalation axis in #1898. #1879 was an unlayered 0,2,1 rule beating a layered author rule; #1854 was a layer registered in the wrong order. Neither is representable in a resolver that has thrown the layers away, so the probe scored both clean.

Rules are now collected by walking the stylesheet rather than stripping it, carrying the layer each rule sits in. Position comes from AuthorCascadeLayerOrder — the reader the engine already uses to pin its own layer order — and ranking is left to CssCascade::compareLayers, which already implements unlayered-wins, later-layer-wins, and the reversal under !important. Walking is also what makes @media inside @layer, and @layer inside @media, resolve correctly.

All 13 new expectations were confirmed against Chromium getComputedStyle, including the two the old resolver got backwards:

later layer, lower specificity wins     probe=blue  chromium=blue
unlayered beats any layer               probe=red   chromium=red
!important reverses layer order         probe=red   chromium=red

Probe-only: engine output over the 90-site corpus is byte-identical to the previous commit. 29-multilingual-i18n improves from 0.7720 to 0.8035 (137 findings to 126) on the more accurate walk.

The stylesheet walk uses the shared syntax scanner (38b11f30)

The walk introduced with layers counted braces itself. CssSyntaxScanner already does that and covers more: comments, parens, brackets, and CSS escapes. The escapes matter here — an escaped identifier can contain the characters that delimit a block, and Tailwind arbitrary-value utilities do exactly that:

.content-\{x\}{color:blue}div{font-size:9px}

The hand-rolled walk read the escaped { as a block opening, lost the rule, and desynchronised from the stylesheet. Selectors reaching the probe through escapes are precisely what this branch exists to make visible, so counting raw braces underneath them defeats the point.

Verification

composer test green — canonical, 311 parity fixtures, packaging.

Production output across the 90-site corpus was diffed commit-to-commit. 4 of 88 entry pages change, each checked individually:

  • 45, 46 — stop emitting dark-scheme values in a default render. Verified against Chromium at colorScheme: light, dark and no-preference.
  • 69 — projects two author rules (:root[data-theme="light"] .seg button[…]) that were previously left pointing at elements the transform had already replaced, i.e. dead CSS.
  • 55 — recognises one further control.

New regression coverage in tests/unit/css-selector-matcher.php (:root support, specificity, matching, attribute filter) and tests/unit/static-css-cascade.php (each closed gap, plus the specificity agreement).

Three findings this surfaced, not addressed here

CssSelectorMatcher::specificity() ignores :where(). It reports 20 for :where(.a.b), where CSS says 0. The parse result already carries zero_specificity; the production function does not read it. Three production call sites rank rules with the wrong number today, including the engine's own :where() guards, which exist specifically to preserve author specificity. Worth its own change with its own blast-radius check.

Render-free parity under-measures dynamic blocks. With the probe now reading the source nav, 45-markdown-ssg-blog reports 7 new presence findings on nav anchors. The cause is structural: core/navigation-link serializes to <!-- wp:navigation-link … /--> with no HTML, so the static candidate document has 0 anchors where the source has 4. The probe is accurate about the document it was handed, but that document is not what WordPress renders.

Scores therefore drop where the probe now sees more:

fixture before after
45-markdown-ssg-blog 0.9873 (3 findings) 0.9361 (10)
8-local-service-business 0.7933 (140) 0.7477 (164)
29-multilingual-i18n 0.8095 (117) 0.7720 (137)
14-restaurant 0.9189 (61) 0.9195 (58)

This matters for #1893's follow-on: a --fail-under gate would partly be gating on that artifact. Scoping coverage to statically-rendered subtrees looks like the honest fix, and it is a separate decision.

An element that loses all its styling leaves the probe set. The probe skips any element with no resolved style and no class/id/style attribute. So when a transform drops an element's styling entirely, the element stops being probed rather than being reported as divergent, and the comparator credits the unmatched source element as absorbed. Reproduced against the pre-#1879 engine: the source resolves summary.md\:hidden to display:none, the candidate still contains a bare <summary>, and because that <summary> carries no class and matches no rule it is skipped — so the comparison scores 1.000 despite the control being visible. This is independent of both viewport and layers, and it is the reason I am not yet claiming this branch would have caught #1879 end to end.

What this does and does not establish

It establishes that the probe now reads the CSS. The source side of the #1879 reproduction resolves summary.md\:hidden to display:none at the reference viewport, which it could not do before: the escaped selector did not match, and the @layer it sits in had been deleted.

It does not yet establish that this branch would have caught #1879 end to end. I tried to demonstrate that against the pre-fix engine and could not, for the reason in the third finding above. The reduced reproduction in tests/unit/disclosure-responsive-display.php also is not cascade-faithful to the site the defect came from — it places the scaffolding display:inline-block unlayered in the author CSS, so the source resolves inline-block too and there is no divergence to find. It drives the carrier correctly, which is what a unit test of the carrier needs, but it cannot stand in for the real cascade.

Proving the end-to-end claim needs the real captured artifact, which is #1893's step 4 (a Tailwind v4 corpus fixture) rather than another synthetic reproduction.

Draft while that is settled.


AI assistance: investigation, implementation and verification with Claude Opus 4.6 via opencode. I directed the work and reviewed every change. The browser measurements, corpus diffs and the pre-/post-fix comparison against 8e38c3f3^ are reproducible from the commands in the commit messages and above.

Several engine projectors emit `:root`-scoped support CSS as a specificity
device, and author stylesheets declare their custom properties on bare
`:root`. CssSelectorMatcher rejected the selector outright, so it could not
read rules the engine itself writes: `specificity()` returned 0 for every
one of them and no call site could match them.

The practical effect was that a `:root` override inside
`@media (prefers-color-scheme: dark)` was resolved as though it were the
base state. On the `45-markdown-ssg-blog` corpus fixture the engine baked
`--ink-soft: #c2bfba` — the dark value — into the navigation link colour,
where Chromium resolves `.nav a` to `rgb(68, 66, 63)` (`#44423f`) in a
default render and only reaches `rgb(194, 191, 186)` under
`prefers-color-scheme: dark`. `46-mdx-design-system` carried the same leak
through `--halo-surface-sunken`.

`:root` is a resting-state structural selector: it matches the document
element and contributes class-level specificity, so it belongs alongside
`:first-child` and `:last-child` rather than with the interaction states.

Corpus effect: 4 of 88 fixtures change. 45 and 46 stop emitting dark-scheme
values in a default render, 69 projects two author rules it previously left
pointing at elements the transform had already replaced, and 55 recognises
one further control.
…lector engine

StaticCssCascade carried its own selector grammar. It recognised `#id`,
`.class`, `tag`, `tag.class…` and combinator chains of those, and nothing
else, so three ordinary shapes silently matched no element:

  .card.wide      tagless compound class
  .card[data-x]   attribute selector
  .md\:hidden     escaped identifier

The third is decisive. Every Tailwind variant utility is an escaped
identifier, so the probe was blind to the entire utility layer of a
Tailwind v4 build — the exact author CSS behind #1865 and #1879. A probe
that cannot read the rule cannot report that the transform dropped it, and
a declaration the author wrote was measured as absent from the source.

Specificity was read by a second, separate grammar, so a selector could be
ranked by one and matched by another: `.md\:hidden` scored 11, counting
`.md` plus a phantom `hidden` type, where CSS says 10.

Both now come from one parse through CssSelectorMatcher, which already
supports these shapes and is the matcher the engine uses in production.
`:is()`, `:where()` and `:not()` arrive with it, and unsupported selectors
fail closed: not matching leaves a declaration missing, while matching
wrongly invents one the author never wrote. Net 114 lines removed.

Specificity keeps the local reading because it accounts for the
`zero_specificity` metadata `:where()` produces, which
CssSelectorMatcher::specificity() does not yet honour.
At-rules were removed textually before the rule grammar was read: `@media`
blocks applying at the reference viewport were inlined, and `@layer` and
`@supports` wrappers were deleted outright.

Deleting the `@layer` wrapper discards the precedence the author chose. A
declaration in a later layer then loses to an earlier layer whenever the
earlier one is more specific, which inverts what the stylesheet says, and
unlayered engine CSS becomes indistinguishable from layered author CSS.
That is the cascade-escalation axis in #1898: #1879 was an unlayered 0,2,1
rule beating a layered author rule, and #1854 was a layer registered in the
wrong order. Neither is representable in a resolver that has thrown the
layers away, so the probe scored both as clean.

Rules are now collected by walking the stylesheet instead of stripping it,
carrying the layer each rule sits in. Layer position comes from
AuthorCascadeLayerOrder, the same reader the engine uses when it pins its
own layer order, and ranking is left to CssCascade::compareLayers, which
already implements unlayered-wins, later-layer-wins, and the reversal under
`!important`. Walking rather than stripping is also what makes a `@media`
inside a `@layer`, and a `@layer` inside a `@media`, resolve correctly.

Nested rule bodies are read for their own declarations only, since `&`
resolution is outside the matcher's grammar and attributing a child's
declarations to its parent would be worse than leaving it unmatched.

All 13 new expectations were confirmed against Chromium's
getComputedStyle for the same markup and CSS, including the two the old
resolver got backwards: a later layer beating a more specific earlier one,
and an unlayered rule beating a layered one regardless of specificity.

Probe-only: engine output over the 90-site corpus is byte-identical.
…tax scanner

The stylesheet walk introduced with cascade layers counted braces itself and
skipped quoted strings by hand. CssSyntaxScanner already does that, and does
more of it: comments, parentheses, brackets, and CSS escapes.

The escapes are not decoration. An escaped identifier can contain the very
characters that delimit a block, and Tailwind arbitrary-value utilities do
exactly that:

  .content-\{x\}{color:blue}div{font-size:9px}

The hand-rolled walk read the escaped `{` as a block opening, so it lost the
rule and desynchronised from the stylesheet. Selectors that reach the probe
through escapes are precisely the ones this branch exists to make visible,
so counting raw braces underneath them defeats the point.

Delegating removes the third scanner this class carried and gets the wider
lexical coverage for free.

Probe-only: engine output over the 90-site corpus is byte-identical.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant