ExplanationMatched rules — architecture and rationale

Matched rules — architecture and rationale

The Styles inspector panel in the editor needs to answer one question for every selected element: what CSS rules apply here, and what does each one set? This page records how we built that primitive, why we shaped it the way we did, and the few things that bit us along the way.

The library is at apps/frontend/src/lib/matched-rules/. Its public API is in Reference → Matched rules library. The first-decision-level summary — in-page implementation, lightningcss-wasm for parsing, Element.matches() for matching, our own cascade — lives in ADR-0001.

Why an in-page implementation at all

Chrome DevTools’ Styles panel answers this question by calling CSS.getMatchedStylesForNode through the Chrome DevTools Protocol. That API is exhaustive, spec-correct, and handled by the same C++ engine that lays the page out — but it’s only reachable from a DevTools client. A normal web page can’t speak CDP to its own browser. Handrix’s preview is a Next.js page, not an Electron shell or a Puppeteer-controlled tab.

What we do have is leverage we wouldn’t have if we were building a generic DevTools clone:

  • The preview iframe is same-origin and the CSS that lands in it is the user’s project CSS, served by us. No cross-origin sheets to lose visibility into.
  • We already parse the user’s CSS before injecting it (the JCSS pipeline). Source positions for every rule and declaration are free — they’re a property of the data we already hold.
  • We control the surface area: we can register one stylesheet per project file and re-register on edits, instead of recovering structure from the DOM after the fact.

That changes the cost of doing it ourselves from “rebuild DevTools” to “wire up a parser, a matcher, and a comparator.” The right cut was to do the whole thing in-page.

The three layers

┌─────────────────────────────────────────────────────────┐
│  parser.ts  ── lightningcss-wasm → AST + source spans   │
│       ↓                                                 │
│  matcher.ts ── Element.matches() over the live DOM      │
│       ↓                                                 │
│  cascade.ts ── sort, resolve, mark overridden           │
└─────────────────────────────────────────────────────────┘

Each layer is replaceable without touching the others, which matters because the matcher is the only place we depend on browser behaviour and the cascade is the only place we’re tracking a moving spec.

Parser: why lightningcss-wasm

We needed a CSS parser that:

  • understood the modern CSS the inspector will encounter — @layer, @container, @scope, CSS Nesting, :has(), :is(), :where(), color spaces, logical properties;
  • exposed structured selectors so we could compute specificity without reparsing strings;
  • carried source positions on every rule node;
  • ran in the browser bundle.

lightningcss-wasm is built on Mozilla’s cssparser and selectors Rust crates — the same code Firefox/Servo uses. We are not writing our own CSS parser; we are reusing one that already passes WPT. That is the load-bearing assumption.

The trade-off is that lightningcss is primarily a transformer. There’s no parse(source): AST entry point exposed in the wasm JS surface. We get the AST by passing a StyleSheet visitor to transform(), capturing the parsed tree, and discarding the transform’s output. It’s a small abuse of the API but mechanically sound — the visitor receives the AST as parsed, before any normalisation.

One thing lightningcss normalises that we have to undo

lightningcss rewrites :has(> img) into :has(:scope > img). The two are spec-equivalent. They are not, however, equally well-supported by Element.matches — older jsdoms (and a few older browsers) silently fail the explicit-:scope form. matcher.ts:stripScopeInFunctionalPseudo undoes the normalisation just for the matching call. The display selector text is unchanged.

Why we slice declaration text from source

lightningcss parses every declaration into a typed value (color: #fff becomes a structured RGBColor etc.). To produce a string for the inspector, we would have to write a serialiser for every property in the union — about 1,500 lines of types. Instead, the parser slices the rule body out of the original source (between the first { after rule.loc and its matching }) and runs a tiny declaration-statement parser over it. We keep:

  • top-level ; as the statement separator, respecting strings and ()/[];
  • the first top-level : as the property/value boundary;
  • ! important at the trailing end as the importance flag;
  • nested { ... } skipped over (those are CSS-Nesting children, handled by their own AST rule).

The byproduct is that the value text in the inspector is exactly what the user typed, including their whitespace and comments. That matters more for an editing surface than a perfectly canonicalised serialisation would.

Matcher: leaning on the browser

Element.matches() is a native call into the engine’s selector matcher — the same C++ code (in Chrome) that runs during style resolution. For each rule branch we:

  1. strip pseudo-elements (Element.matches throws on them);
  2. strip any pseudo-classes in options.activePseudoClasses so .btn:hover matches an un-hovered button when the inspector is in “hover” preview;
  3. call el.matches(selector).

Combinators (>, +, ~, descendant) and nested functional pseudo-classes are not special-cased — they’re whatever the browser does. The whole matcher is about a hundred lines because the hard work isn’t ours.

When includeInherited is set, we walk ancestors and try the same matcher against each. The result is tagged inherited: true so the inspector can render them in the “Inherited from” section.

Cascade: the comparator

The CSS cascade has a few parts that are easy to get wrong. We chose to encode them in a single comparator with named tiers (cascade.ts:sortByCascade) so the spec lookup is exactly one function:

inherited last
  → pseudo-element grouping
    → importance × layer
      → specificity (a, b, c desc)
        → source order (later wins)

The importance-and-layer interaction is the part that tripped us up reading the spec. Within an origin:

OriginLayer ordering
Normal declarationsUnlayered beats layered. Among layered, later layer beats earlier.
!important declarationsReversed — earlier layer beats later. Unlayered !important is the weakest important origin.

That’s CSS Cascade 5 §6.4.4. The TL;DR is that !important flips the precedence relationship across layers, so a system designer can put their “really shouldn’t be overridden” rules in the first layer and know that anything !important in later layers will lose. We encode this as a single integer key with two bands (importanceLayerKey) so the comparator is still one expression.

Per-property resolution

getMatchedRules returns rules. resolveDeclarations reduces those rules to one value per property — applying !important correctly, marking losers as overridden. The two are split because they answer different questions, but the second is implemented in terms of the first comparator.

resolveDeclarations mutates the input. Specifically, every losing declaration on the source rules has overridden set to true. That mutation is on purpose: the inspector needs to render a strike-through on overridden values, and the most ergonomic way to do that without a parallel data structure is to look at declaration.overridden directly on the rules it’s already drawing.

Shorthand expansion

The spec for resolveDeclarations was originally to expand every shorthand into its longhands using lightningcss, then group by longhand. lightningcss does know every shorthand — it expands them internally during transformation. What it doesn’t expose is just the expansion, callable from JS on a single declaration. We’d have to round-trip through transform() per declaration, which would be both wasteful and obscure.

For v1 we wrote a hand-rolled expander (shorthand.ts) for the box shorthands where the 1-to-4-value pattern is well-defined and the user is most likely to mix shorthand and longhand:

  • margin, padding, inset, scroll-margin, scroll-padding → top/right/bottom/left
  • border-radius → top-left / top-right / bottom-right / bottom-left

Properties that aren’t in that table pass through unchanged. The visible consequence is that margin: 10px; margin-top: 20px in a single rule resolves margin-top correctly (the longhand wins) and shows three separate margin-* entries from the shorthand; the un-expanded case (background: red; background-color: blue) shows two property groups instead of one. We accept that limitation in exchange for not maintaining a 30-shorthand expansion table.

The jsdom prototype pitfall

We hit one non-obvious environment issue worth recording, because the failure mode is silent and the diagnosis takes a while.

Under jest+jsdom, globalThis.Uint8Array is the jsdom-context constructor. Node’s built-in Buffer and util.TextEncoder produce Uint8Arrays whose [[Prototype]] is the Node Uint8Array.prototype. The two prototypes are not the same object — so bufFromNode instanceof globalThis.Uint8Array is false. lightningcss-wasm’s input check uses instanceof and rejects the bytes with an error message (“Expected $name, got -1”) that does not point at the real cause.

The fix is in parser.ts:encodeUtf8 and is a single copy:

function encodeUtf8(s: string): Uint8Array {
    const native = new TextEncoder().encode(s);
    if (native instanceof Uint8Array) return native;
    const out = new Uint8Array(native.byteLength);
    out.set(native);
    return out;
}

In real browsers native instanceof Uint8Array is always true and the copy never fires. In jest+jsdom it always fires, once per call to parseStylesheet. The cost is negligible and the function works in both realms without an environment flag.

Two related concessions in jest.setup.ts exist for the same reason:

  • We polyfill TextEncoder/TextDecoder from util (jsdom doesn’t expose them).
  • We point document.currentScript.src at the path to wasm-node.cjs inside the lightningcss-wasm package, so the package’s import.meta.url polyfill — which prefers document.currentScript.src when document exists — resolves the wasm file to a real path on disk instead of about:blank?lightningcss_node.wasm.

If you ever migrate the test runner off jest+jsdom (e.g. to Vitest with happy-dom), revisit both. The production code is unaffected.

What’s deferred to phase 2

We made the call to ship v1 without these and document them as known limitations:

  • @container query evaluation. Rules inside @container are surfaced with the wrapping condition but excluded from the active cascade. Evaluating them requires walking up to the matching container ancestor and resolving the query against that element’s size — doable, but not necessary for the inspector’s first version where the dominant use case is @media.
  • @scope. Same treatment, same reasoning.
  • Animation/transition resolution. The cascade returns static values. Animated/transitioning final values come from getComputedStyle() separately.
  • Custom-property substitution. var(--x) is returned verbatim. The inspector can show both the authored value and a resolved value via getComputedStyle(el).getPropertyValue(prop).
  • A selector pre-filter for large stylesheets. Element.matches() is native and fast, but it’s still O(rules) per query. For 10k-rule sheets a class/id/tag pre-filter on the element’s ancestor chain would help. We didn’t profile a real bottleneck yet, so we haven’t built it.