ReferenceMatched rules library

Matched rules library

apps/frontend/src/lib/matched-rules returns every CSS rule from registered stylesheets that affects a given element, in cascade order, with source positions. It is the foundation for the Styles inspector panel.

This page is hand-written. Drift between this page and apps/frontend/src/lib/matched-rules/types.ts is a bug — fix the page when the types move.

Module layout

FileRole
index.tsPublic API surface
types.tsPublic + internal types
parser.tslightningcss-wasm wrapper, AST walking, condition stack, layer tracking, declaration body slicing, CSS Nesting via & substitution
specificity.tsW3C Selectors L4 a/b/c specificity
selector-serializer.tsSelector AST → string; pseudo-element extraction/stripping
matcher.tsElement.matches() over the live DOM; ancestor walk for inheritance; active-pseudo-class simulation
cascade.tsCascade comparator and layer index
declarations.tsPer-property resolution with overridden markup
shorthand.tsBox-shorthand expansion for margin/padding/inset/border-radius
conditions.ts@media/@supports evaluation
registry.tsStylesheet store keyed by caller-provided id

Initialization

initMatchedRules(): Promise<void>

Initializes the underlying lightningcss-wasm module. Must be awaited once per iframe boot before any other call. Subsequent calls return the cached promise.

import { initMatchedRules } from "@/lib/matched-rules";
 
await initMatchedRules();

Stylesheet registry

The matcher only sees stylesheets that have been registered. Insertion order is preserved and feeds the cascade comparator’s source-order tiebreak. The registry is keyed by a caller-provided id; HANDRIX passes the database file ID it already holds for each CSS file.

registerStylesheet(source: string, id: string, displayPath?: string): void

Parses source once and stores it under id. If an entry with the same id already exists it is replaced — re-calling registerStylesheet is the update mechanism, so there is no separate updateStylesheet. displayPath is the path the inspector should show for “jump to source” (e.g. "src/styles/app.css") and populates SourcePosition.file on every emitted SourceRange; when omitted, SourcePosition.file falls back to the id.

// Phase 2: a single database file may produce multiple registered stylesheets
// (e.g. through @import expansion or CSS-in-JS emitting several runtime sheets).
// When that happens, callers will need a composite key like `${id}:${subIndex}`;
// until then the one-file-one-id assumption holds.

unregisterStylesheet(id: string): void

Removes the stylesheet stored under id. Subsequent getMatchedRules calls do not see it. No-op if no stylesheet with that id is registered.

clearRegistry(): void

Removes every registered stylesheet. Use on iframe teardown or between tests.

Why the caller owns the identity

HANDRIX already has a stable ID for every CSS file in its database; reusing it as the registry key gives a single source of truth across the editor, file tree, and git layers, and keeps registrations coherent across reloads and rehydration. The registry stays simple — no separate handle to track, no mapping between database IDs and registry handles to keep in sync.

Usage

await initMatchedRules();
for (const file of project.cssFiles) {
    registerStylesheet(file.source, file.id, file.path); // path optional
}
const matched = getMatchedRules(target);

Queries

getMatchedRules(element: Element, options?: GetMatchedRulesOptions): MatchedRule[]

Returns every rule from the registered stylesheets that affects element. By default the result is sorted in cascade order, winner first; rules whose conditions don’t currently match are excluded.

const matched = getMatchedRules(targetEl, {
    includeInherited: true,
    activePseudoClasses: ["hover", "focus"],
    pseudoElements: "all",
});

The returned objects are independent of internal state — mutating them does not affect future calls — except that resolveDeclarations will set overridden: true on losing declarations (see below).

resolveDeclarations(matched: MatchedRule[]): Record<string, ResolvedProperty>

Reduces matched rules to one winning value per property, with !important applied. Expands the box shorthands listed under shorthand.ts before grouping. The result is keyed by property name; rules targeting pseudo-elements are keyed with a ::name/property prefix so they don’t collide with the element’s own rules.

Mutates the input: every losing declaration on the source rules has overridden set to true. The mutation is intentional — it lets the inspector render strike-through overrides without holding a parallel index.

getInlineStyle(element: Element): MatchedDeclaration[]

Convenience accessor for an element’s inline style declarations. Returned with source ranges that point to <inline> because inline styles have no file position. important reflects style.getPropertyPriority(prop) === "important".

Options

GetMatchedRulesOptions

FieldTypeDefaultMeaning
includeInheritedbooleanfalseAlso include rules matching any ancestor of the element. Inherited rules are tagged inherited: true.
activePseudoClassesstring[][]Pseudo-class names (with or without leading :) to treat as currently matching for selectors like .btn:hover. Functional pseudo-classes (e.g. :nth-child) are unaffected.
pseudoElementsstring[] | "all" | "none""all"Filter the pseudo-element-targeting rules included. "none" strips them. An array (without :: prefix) keeps only those names.
sortbooleantrueWhen false, results are emitted in source order rather than cascade order.

Public types

MatchedRule

FieldTypeMeaning
idstringStable, derived from stylesheet id + source position. Use for editing round-trips.
selectorTextstringFull selector as authored (comma-joined branches).
matchedSelectorstringThe single branch from selectorText that matched, including any pseudo-element.
specificitySpecificityOf the matched branch.
declarationsMatchedDeclaration[]Declarations parsed from the rule body.
conditionsRuleCondition[]All wrapping at-rules, outermost first.
layerstring | nullDotted layer name if inside a layer (e.g. "framework.base"), else null.
sourceSourceRangeRule’s start/end position in the user’s source.
inheritedbooleanTrue iff the rule matched an ancestor rather than the element itself.
pseudoElementstring | nullPseudo-element name without ::, else null.
sourceOrdernumberCascade-order tiebreak index. Lower = earlier in source.

MatchedDeclaration

FieldTypeMeaning
propertystringProperty name as authored.
valuestringValue text, with !important stripped.
importantbooleanTrue iff the original declaration ended with !important.
sourceSourceRangeDeclaration’s start/end position.
overriddenbooleanMutated to true by resolveDeclarations when another rule wins this property.

ResolvedProperty

FieldTypeMeaning
propertystringThe longhand property name.
valuestringWinning value.
importantbooleanWhether the winning declaration carried !important.
winningRuleIdstringMatchedRule.id of the rule that won.
competitorsArray<{ ruleId: string; value: string; important: boolean }>Every contender for this property in cascade order, winner first.

Specificity

interface Specificity {
    a: number; // ID selectors
    b: number; // classes, attributes, pseudo-classes (except :where)
    c: number; // type selectors, pseudo-elements
}

:where(...) always contributes (0, 0, 0). :is(...), :not(...), :has(...), :matches(...) contribute the maximum specificity across their argument selector list.

SourcePosition and SourceRange

interface SourcePosition {
    file: string; // `displayPath` from registerStylesheet, or the stylesheet `id` if none was provided
    line: number; // 1-indexed
    column: number; // 1-indexed
}
 
interface SourceRange {
    start: SourcePosition;
    end: SourcePosition;
}

file is displayPath ?? id. Consumers that need the underlying registry key (for editing round-trips or programmatic stylesheet replacement) should track it separately rather than parsing it out of file.

RuleCondition

interface RuleCondition {
    type: "media" | "supports" | "layer" | "container" | "scope";
    text: string; // condition text as authored
    matches: boolean; // currently satisfied
}

@layer always reports matches: true — layers affect ordering, not whether a rule applies. @container and @scope always report matches: false in v1 and are excluded from the cascade (see the explanation page for the phase-2 plan).

Cascade order

getMatchedRules sorts by, in order:

  1. Element-self rules before inherited rules.
  2. Non-pseudo-element rules before pseudo-element rules; within pseudo, group by name.
  3. Importance × layer:
    • Normal declarations: unlayered beats layered; among layered, later layer beats earlier.
    • !important declarations: order reverses — earlier layer beats later, and unlayered !important is the weakest important origin.
    • Across the importance dimension, !important beats normal.
  4. Specificity (a, then b, then c; higher wins).
  5. Source order — later in source wins.

The same comparator is used per-property by resolveDeclarations to decide overridden state.

Limitations

  • @container and @scope are recognized but not evaluated; rules wrapped in them appear with matches: false on the condition and are excluded from the cascade.
  • CSS animations and transitions are not in the cascade; consult getComputedStyle for the final rendered value.
  • UA stylesheet is not included.
  • Custom property substitution is not performed here — var(--x) is returned verbatim as the declaration value. Consult getComputedStyle for substituted values.
  • Shorthand expansion is partial: the box shorthands listed under shorthand.ts are expanded; others pass through unchanged. See the explanation for why.

Errors

The API does not throw for malformed CSS — lightningcss-wasm is invoked with errorRecovery: true and a sufficiently broken stylesheet yields an empty parsed sheet rather than an exception.