ADR-0001: Matched-rules library is in-page and uses lightningcss-wasm
Context
The Styles inspector panel in the editor needs to surface every CSS rule that affects a selected element in the preview iframe, with source positions, correct specificity, and cascade-aware ordering. This is the foundational primitive everything else in the panel will build on: read, click-to-source, edit-and-round-trip, override visualisation.
Chrome DevTools answers the same question with CSS.getMatchedStylesForNode, but that API is reachable only from DevTools clients (extensions, Puppeteer/Playwright, Electron). Handrix’s preview is a normal same-origin iframe inside a Next.js app — we cannot speak the DevTools Protocol to the browser running our code.
We also already control the CSS that lands in the preview: we parse the user’s project CSS server-side, inject it, and own its lifecycle. Source positions are not something we have to recover from the DOM — they’re a property of the data we already hold.
Decision
Build the matched-rules library in the preview iframe’s JavaScript, with three replaceable layers:
- Parser —
lightningcss-wasm(Mozilla’scssparser+selectorscrates compiled to wasm) for AST and source positions. We feed it the user’s CSS and capture the parsedStyleSheetvia thetransform()visitor’sStyleSheetcallback. The transform output is discarded; we only want the AST. - Matcher —
Element.matches()against the live DOM for each selector branch. Strip pseudo-elements before the call; strip user-action pseudo-classes for theactivePseudoClassespreview mode. - Cascade — our own comparator over the AST-derived metadata: inherited → pseudo-element grouping → importance × layer (with the
!importantlayer reversal of CSS Cascade 5 §6.4.4) → specificity → source order. Per-property resolution sits on top.
Declaration values are sliced out of the original source text (between { ... }) rather than serialised from the typed Declaration union. The library exposes a small stylesheet registry keyed by a caller-provided id (HANDRIX uses the database file ID); the inspector and the editor’s CSS pipeline register/unregister sheets as files change, with re-registering under the same id acting as the update path.
Alternatives considered
-
Connect a DevTools Protocol bridge. Rejected. Web pages cannot reach CDP for their own tab. Wrapping the preview in an Electron shell or driving it through Playwright would unlock the API but commits us to a second runtime for what is fundamentally a web app. The cost dwarfs the benefit.
-
Use
document.styleSheetsandCSSOMdirectly. Rejected. CSSOM gives us rules but loses source positions (we’d need a separate source map per stylesheet), normalises selectors in browser-specific ways, throws on any cross-origin sheet (a constant footgun even when we control the sheets today), and does not expose structured selector ASTs — making specificity and:is/:not/:hascorrect is then on us with string parsing. -
Use the existing
cssomnpm dependency. Rejected.cssomparses but doesn’t model modern CSS — no@layer, no@container, no CSS Nesting, no:has()understanding. The whole point of choosing a parser is to inherit support for these. Reaching for our existing dep would force a rewrite the first time the inspector encountered a real project’s CSS. -
Hand-write a CSS parser. Rejected. The selector grammar alone —
:nth-child,:is,:not,:has,:where, attribute selectors with case sensitivity, namespaces, CSS Nesting,@scopeselectors — is a multi-week project and a perpetual maintenance burden. lightningcss is built on Mozilla’s production parser; we are not better positioned to write a third one. -
Use
lightningcss(native, not wasm). Rejected for the in-page case. The native package ships platform binaries and won’t run in the browser. We use the wasm build, which is ~3MB but loads once and parses every project’s CSS for the life of the iframe. -
Defer to phase 2 and ship the inspector with a thinner approximation. Rejected. Every shortcut at this layer becomes a debt the inspector has to work around for the rest of its life. Better to spend the parser/cascade budget once than to retrofit specificity computation into a “good enough” first cut later.
Consequences
Positive.
- The inspector can show real source positions out of the gate — file, line, column for every rule and every declaration. Click-to-source is mechanical.
- Specificity,
:is/:not/:has/:wherearithmetic,@layerprecedence with the!importantreversal, and CSS-Nesting selector composition all work in v1. - The three layers are independent: replacing the parser, swapping the matcher for a more conservative one, or evolving the cascade comparator can each be done without touching the others.
- The matcher delegates to
Element.matches, which is the browser’s own selector engine. Combinator and selector-feature support is whatever the browser supports — we don’t have to ship a matching engine.
Negative.
- We carry a ~3MB wasm bundle. It’s loaded once per iframe; not a meaningful problem for a tool the user is actively editing in, but worth knowing.
- lightningcss is primarily a transformer; using it as a parser means we go through
transform()and discard the output. The API works but isn’t designed for our use case, and an upstream change to the visitor contract could affect us. - Declaration values are sliced from source text rather than serialised from the typed AST. The slice is exactly what the user typed (a feature for editing), but it means a per-rule O(n) scan over the body. We profiled this on real project CSS; the cost is negligible.
- The matcher is bound by
Element.matchesquirks. We worked around one already: lightningcss normalises:has(> img)to:has(:scope > img), which some matchers silently fail; we strip the:scopebefore callingmatches. Future quirks will need similar one-line fixes. @container,@scope, custom-property substitution, and animations are not in the cascade in v1. They are documented as known limitations and have a phase-2 plan in the explanation page.
Test environment. Running under jest+jsdom requires three small accommodations in jest.setup.ts — a TextEncoder/TextDecoder polyfill, a document.currentScript.src override so lightningcss-wasm’s import.meta.url polyfill resolves the wasm path, and a one-line Uint8Array realm copy in encodeUtf8 to satisfy lightningcss’s instanceof check. None affects production. If we migrate test runners, revisit all three.