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
| File | Role |
|---|---|
index.ts | Public API surface |
types.ts | Public + internal types |
parser.ts | lightningcss-wasm wrapper, AST walking, condition stack, layer tracking, declaration body slicing, CSS Nesting via & substitution |
specificity.ts | W3C Selectors L4 a/b/c specificity |
selector-serializer.ts | Selector AST → string; pseudo-element extraction/stripping |
matcher.ts | Element.matches() over the live DOM; ancestor walk for inheritance; active-pseudo-class simulation |
cascade.ts | Cascade comparator and layer index |
declarations.ts | Per-property resolution with overridden markup |
shorthand.ts | Box-shorthand expansion for margin/padding/inset/border-radius |
conditions.ts | @media/@supports evaluation |
registry.ts | Stylesheet 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
| Field | Type | Default | Meaning |
|---|---|---|---|
includeInherited | boolean | false | Also include rules matching any ancestor of the element. Inherited rules are tagged inherited: true. |
activePseudoClasses | string[] | [] | 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. |
pseudoElements | string[] | "all" | "none" | "all" | Filter the pseudo-element-targeting rules included. "none" strips them. An array (without :: prefix) keeps only those names. |
sort | boolean | true | When false, results are emitted in source order rather than cascade order. |
Public types
MatchedRule
| Field | Type | Meaning |
|---|---|---|
id | string | Stable, derived from stylesheet id + source position. Use for editing round-trips. |
selectorText | string | Full selector as authored (comma-joined branches). |
matchedSelector | string | The single branch from selectorText that matched, including any pseudo-element. |
specificity | Specificity | Of the matched branch. |
declarations | MatchedDeclaration[] | Declarations parsed from the rule body. |
conditions | RuleCondition[] | All wrapping at-rules, outermost first. |
layer | string | null | Dotted layer name if inside a layer (e.g. "framework.base"), else null. |
source | SourceRange | Rule’s start/end position in the user’s source. |
inherited | boolean | True iff the rule matched an ancestor rather than the element itself. |
pseudoElement | string | null | Pseudo-element name without ::, else null. |
sourceOrder | number | Cascade-order tiebreak index. Lower = earlier in source. |
MatchedDeclaration
| Field | Type | Meaning |
|---|---|---|
property | string | Property name as authored. |
value | string | Value text, with !important stripped. |
important | boolean | True iff the original declaration ended with !important. |
source | SourceRange | Declaration’s start/end position. |
overridden | boolean | Mutated to true by resolveDeclarations when another rule wins this property. |
ResolvedProperty
| Field | Type | Meaning |
|---|---|---|
property | string | The longhand property name. |
value | string | Winning value. |
important | boolean | Whether the winning declaration carried !important. |
winningRuleId | string | MatchedRule.id of the rule that won. |
competitors | Array<{ 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:
- Element-self rules before inherited rules.
- Non-pseudo-element rules before pseudo-element rules; within pseudo, group by name.
- Importance × layer:
- Normal declarations: unlayered beats layered; among layered, later layer beats earlier.
!importantdeclarations: order reverses — earlier layer beats later, and unlayered!importantis the weakest important origin.- Across the importance dimension,
!importantbeats normal.
- Specificity (
a, thenb, thenc; higher wins). - Source order — later in source wins.
The same comparator is used per-property by resolveDeclarations to decide overridden state.
Limitations
@containerand@scopeare recognized but not evaluated; rules wrapped in them appear withmatches: falseon the condition and are excluded from the cascade.- CSS animations and transitions are not in the cascade; consult
getComputedStylefor 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. ConsultgetComputedStylefor substituted values. - Shorthand expansion is partial: the box shorthands listed under
shorthand.tsare 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.