Resolve static parity through the production selector engine - #1908
Merged
Merged
Conversation
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.
chubes4
marked this pull request as ready for review
September 17, 2026 02:50
This was referenced Sep 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
StaticStyleParityRunneron the #1879 reproduction, against this branch's merge base and against8e38c3f3^(the commit before #1879 was fixed):Identical before and after the fix. The
escapedvariant is the real Tailwind shape and the probe scores it a clean pass either way.What was wrong
StaticCssCascadecarried 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:.card.wide.card[data-x].md\:hiddenThe 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\:hiddenscored 11, counting.mdplus a phantomhiddentype, where CSS says 10.Changes
:rootin 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, butCssSelectorMatcherrejected the selector outright —specificity()returned 0 for every one and no call site could match them.The effect was that a
:rootoverride inside@media (prefers-color-scheme: dark)resolved as though it were the base state. Measured in Chromium on the45-markdown-ssg-blogcorpus fixture:The engine was baking
#c2bfba— the dark value — into the navigation link colour of a default render.46-mdx-design-systemcarried the same leak through--halo-surface-sunken.:rootis a resting-state structural selector: it matches the document element and contributes class-level specificity, so it sits alongside:first-childand:last-childrather 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_specificitymetadata:where()produces, whichCssSelectorMatcher::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
@layerwrappers 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,1rule 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 toCssCascade::compareLayers, which already implements unlayered-wins, later-layer-wins, and the reversal under!important. Walking is also what makes@mediainside@layer, and@layerinside@media, resolve correctly.All 13 new expectations were confirmed against Chromium
getComputedStyle, including the two the old resolver got backwards:Probe-only: engine output over the 90-site corpus is byte-identical to the previous commit.
29-multilingual-i18nimproves 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.
CssSyntaxScanneralready 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: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 testgreen — 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:
colorScheme: light,darkandno-preference.:root[data-theme="light"] .seg button[…]) that were previously left pointing at elements the transform had already replaced, i.e. dead CSS.New regression coverage in
tests/unit/css-selector-matcher.php(:rootsupport, specificity, matching, attribute filter) andtests/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 carrieszero_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-blogreports 7 newpresencefindings on nav anchors. The cause is structural:core/navigation-linkserializes 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:
This matters for #1893's follow-on: a
--fail-undergate 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/styleattribute. 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 resolvessummary.md\:hiddentodisplay: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\:hiddentodisplay:noneat the reference viewport, which it could not do before: the escaped selector did not match, and the@layerit 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.phpalso is not cascade-faithful to the site the defect came from — it places the scaffoldingdisplay:inline-blockunlayered in the author CSS, so the source resolvesinline-blocktoo 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.