ExplanationArchitecture Decision RecordsADR-0002: Force element states with an injected stylesheet

ADR-0002: Force element states with an injected, EID-scoped stylesheet

Context

Designers need to see and edit an element’s :hover, :focus, :active and :visited styles. The states only exist during real interaction, and moving the pointer to the panel ends the hover. Pages cannot set these states from script; only DevTools (via the protocol, unavailable to us — see ADR-0001) can force them.

Two separate needs follow: the rule list and edit routing must treat the element as being in the state, and the canvas must render it in that state.

matched-rules already had activePseudoClasses for the first need, implemented by deleting the pseudo-class from the selector before Element.matches. Deletion turned .a:not(:hover) into the invalid .a:not(), which silently stopped matching.

Decision

  1. Matching: activePseudoClasses replaces each listed pseudo-class with :is(*) instead of deleting it. .btn:hover → .btn:is(*) matches; .a:not(:hover) → .a:not(:is(*)) correctly fails while hover is simulated. Names match exactly, so focus does not touch :focus-within.
  2. Rendering: buildForcedStateCss(state, target) copies every registered selector branch containing :state, replacing the pseudo-class with :is([data-eid="…"]), re-wrapping @media/@supports/@container/@scope, and keeping !important. StyleControllerPanel injects the result as <style id="handrix-forced-state" data-handrix-editor> into the iframe head and removes it on state change, selection change and unmount.
  3. UI: a state selector next to the rule dropdown, defaulting to neutral and reset on selection change. Neutral lists only currently-matching rules; a state adds that state’s rules. GeneralStylePanel routes edits with the forced state; SourceBtn’s target list always includes all states.

Alternatives considered

  • Add a class or attribute to the element (e.g. data-hx-state="hover") and rewrite rules to target it — rejected: it mutates the element the editor serializes. Delete-with-undo reads outerHTML and would capture it; any DOM→JHTML path risks persisting it.
  • Rewrite the user’s stylesheets in place — rejected: those sheets are the source of truth for editing round-trips and source positions.
  • Global :hover → * rewrite (force the state everywhere) — rejected: every hover rule on the page would apply at once.
  • Delete the pseudo-class when matching (previous behavior) — rejected for the :not() bug above.

Consequences

  • The page itself is never changed; forcing a state is safe to leave on.
  • Scoping by data-eid forces the state on the selected element only, like DevTools. Real hover also applies to ancestors, so .card:hover .title renders only when the card is the forced element. The rule list (via activePseudoClasses) assumes the state on every compound, so it may list that rule when the title is selected even though the canvas doesn’t show it.
  • The injected sheet comes after all user sheets. With equal specificity, a forced rule can beat a normal rule that followed it in the source. @layer placement is not reproduced.
  • :is(<attribute selector>) keeps specificity at (0,1,0), equal to the pseudo-class it replaces.
  • getSpecifiedStyle reads document.styleSheets, so top-level forced rules appear in the Style target’s values. Forced rules inside @media are rendered but, like other conditional rules, not read by getSpecifiedStyle.