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
- Matching:
activePseudoClassesreplaces 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, sofocusdoes not touch:focus-within. - 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.StyleControllerPanelinjects 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. - 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.
GeneralStylePanelroutes 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 readsouterHTMLand 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-eidforces the state on the selected element only, like DevTools. Real hover also applies to ancestors, so.card:hover .titlerenders only when the card is the forced element. The rule list (viaactivePseudoClasses) 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.
@layerplacement is not reproduced. :is(<attribute selector>)keeps specificity at (0,1,0), equal to the pseudo-class it replaces.getSpecifiedStylereadsdocument.styleSheets, so top-level forced rules appear in the Style target’s values. Forced rules inside@mediaare rendered but, like other conditional rules, not read bygetSpecifiedStyle.