Skip to content

Behavior graph & motion system

Status: current — 2026-09-13 · Owner: editor / interactions

The visual node-graph editor for wiring cross-element interactions, its ⚡ canvas-wires companion view, variant-state switching, and the read-only Motion panel audit surface — shipped as D83/D:behavior-graph (2026-07-14). This system has had essentially zero prior durable-reference documentation; everything below is written directly from the code and from DECISIONS.md’s D83/D83a entries (cited, not edited by this doc).


Before D83, an element’s behaviors (interactions.behaviors on a dom_nodes row — trigger → action pairs like “on click, toggle open”) were authorable one element at a time in a property-panel list, with no way to see how elements wired to each other across a page, and no way to switch a component’s variant (a library/component-instance look) as a behavior action at all — variant switching had no runtime path prior to this work (three unreconciled variant systems existed, all edit/drop-time only). D83 closed both gaps: it added a runtime-representable variant-switch action (§3) and two new visual surfaces over the existing behavior data — the canvas wires overlay and the full-page node graph (§1, §2) — without inventing a second interaction data model. Everything the graph draws or lets you author is still exactly dom_nodes.interactions.behaviors; nothing about the graph is persisted as graph state.

The state-switching runtime core itself predates D83 (D50c: set / remove / toggle / exclusive / step actions compiled to state-<name> CSS classes, CSP hash-pinned, already running end-to-end in the published output). D83’s job was making that runtime capable of a variant switch and making the whole wiring graph visible and editable rather than implicit in a per-element list.


frontend/src/lib/behaviors/wire-model.ts is the single source every visual surface reads from — edges are always derived fresh from dom_nodes.interactions.behaviors; neither the graph nor the wires overlay persists any graph state of its own. This is stated as a deliberate invariant in the file’s own header and repeated in both consuming components’ headers.

One edge per resolved target of one behavior (a behavior with multiple target_node_ids becomes multiple edges sharing a behaviorIndex and behavior object). Fields worth knowing:

  • sourceId/targetId — a self-targeting behavior (no explicit targets, or explicitly targeting its own node) produces sourceId === targetId.
  • trigger/action/state/value/scope — the behavior’s own vocabulary; scope === 'item' marks a repeater-clone-scoped edge (rendered as a “per item” chip in both overlays).
  • inert — !isRuntimeExecutableAction(action) (from @matriz/site-render): an action the published runtime will silently drop — most commonly leftovers from an import. Both overlays render inert edges dashed amber with a ⚠, and the Motion panel (§4) shows the same “inert” pill — one shared enumeration, not three that can disagree.
  • isVariantSwitch — true when the target state starts with variant- and the action is set_state/exclusive_state/toggle_state (§3).
  • threshold/once — viewport-trigger tuning, surfaced explicitly (D:trigger-vocabulary-guards) so a scroll-triggered edge that only fires at 90% visibility, or only once, doesn’t render identically to one with no such tuning — “why didn’t that fire when I expected” is a named failure mode this is meant to prevent.

activeBehaviorsAt(node, breakpointId) resolves the behavior stack active at that breakpoint layer via resolveInteractionsForBreakpoint — a byBreakpoint override replaces the base stack wholesale, the same whole-stack-override semantics as the published runtime and the property panel. Both overlays and the graph model always pass the editor’s current breakpoint, so what you see wired is what that viewport will actually run.

Page-scope budgeting (D:behavior-wires-page-scope)

Section titled “Page-scope budgeting (D:behavior-wires-page-scope)”

PAGE_WIRE_ID_BUDGET = 240 is not a taste choice — it’s the canvas bounds-tracking protocol’s own ceiling. preview.html’s startBoundsWatch slices tracked ids to 300; every tracked id costs a getBoundingClientRect per element on every scroll-driven animation frame, batched into an elementBoundsBatch postMessage. A real import can blow straight through that (the 12×12 capture cited in the code: 1453 edges over 975 elements). budgetEdgesForBounds(edges, focusId, budget) trims deterministically: edges touching focusId first (the element the author is looking at), then the rest in document order, and an edge is admitted only if both endpoints fit the budget (a wire with one untracked end can’t be drawn at all). The point of doing this explicitly, per the code’s own framing, is keeping the truncation ours — decided here, prioritized predictably, and reportable in the UI — instead of an invisible slice happening one postMessage away.


buildBehaviorGraph(nodes, breakpointId, pinnedIds) builds the node-graph view: a graph node exists for every page element that participates in at least one edge, plus any explicitly pinned ids (e.g. the current selection, via “Add element” in the UI). Column assignment (assignColumns) lays sources left of their targets — but is explicitly cycle-safe, not a DAG topological sort: two-way toggle wiring is common (A toggles B, B toggles A), so columns are computed by a bounded relaxation (col(target) >= col(source) + 1, applied for up to min(ids.length, 12) rounds, a cycle simply stops the relaxation from converging further rather than looping forever). Row order within a column is stable — tree order, not edge-discovery order — so the same page lays out the same way across renders. Edges whose endpoints aren’t present on the page (stale references from an imported target) are dropped rather than rendered as dangling wires with no card.


BehaviorWiresOverlay.tsx — the ⚡ canvas layer

Section titled “BehaviorWiresOverlay.tsx — the ⚡ canvas layer”

SVG wires drawn over the canvas iframe for the selected element’s outbound and inbound behaviors, labeled "trigger → action state". Element positions come from the canvas’s existing watchElementBounds/ elementBoundsBatch postMessage subscription (scroll/resize-tracked), so wires stay glued to elements as the page scrolls. Visual encoding:

  • Outbound wires: primary color. Inbound: muted.
  • Repeater-scoped (item) edges: a “per item” chip.
  • Inert edges: dashed amber + ⚠ (§ wire-model above).
  • Variant switches: a diamond marker on the label.

Two scope modes (D:behavior-wires-page-scope), a header chip toggle:

  • Selection — the original inspector: one element’s edges, all labeled.
  • Page — every edge on the page at once, a prototype-mode map. At real density (1453 edges on the cited capture) full labeling is unreadable, so page mode dims the field and labels only edges touching the current selection plus whichever wire is hovered — but all wires are always drawn; the map itself is the point, not just the labeled subset.

Clicking a wire’s label selects the edge’s source element (its behavior stack lives there, so that’s where editing happens) — the graph view (below) is the actual editing surface, the canvas overlay is primarily an inspector.

BehaviorGraphOverlay.tsx — the full-page node graph

Section titled “BehaviorGraphOverlay.tsx — the full-page node graph”

Opened from the ⚡ wires overlay (behaviorGraphOpen). A strict view + gesture layer per its own header comment: nodes/edges are recomputed fresh from the store on every render, and the only writes go through behavior-write.ts (§ below) and variant-state materialization (§3). Card drag positions are session-local UI state, never persisted.

Gestures:

  • Drag a card by its body — rearrange the view (visual only).
  • Drag from a card’s ⚡ output port onto another card — creates a new behavior via an inline form (trigger + action + state/variant). The offered triggers (TRIGGER_OPTIONS) and actions (ACTION_OPTIONS) are deliberately kept to the same catalog presets the property-panel Behaviors editor uses, so a graph-created behavior is editable in both places. Note from D:trigger-vocabulary-guards: the trigger list was once a subset of what TRIGGER_SHORT_LABELS could already display (6 of 11) — a wire drawn from an imported scroll behavior would render fine but could never be re-created here after deletion. Both lists are now the same 11 triggers (click, double-click, hover, mouse-leave, focus, blur, key-down, scroll, page-load, scroll-into-view, scroll-out-of-view).
  • Click an edge label — inspect it: delete, or jump to the source element.
  • “Add element” — pin any page element into the graph even if it has no edges yet, so it can be wired up from a blank state.

Variant-state switching (§3, variant-states.ts + variant-state-commands.ts)

Section titled “Variant-state switching (§3, variant-states.ts + variant-state-commands.ts)”

“Switch variant” is authoring-level sugar, not a new runtime verb. When an author wires a behavior that switches a target element to a variant, the system materializes that variant’s look as a variant-<name> custom state layer inside the target’s style_overrides, and serializes a plain set_state behavior with target_state: 'variant-<name>'. The published interactions runtime (and the preview.html canvas mirror) already treats variant-* states as mutually exclusive per element, so one set_state call is a switch — no new runtime verb, no CSP change required, and preview↔published parity is free because .state-variant-<name> CSS compiles exactly like any other custom state.

Variant sources, in precedence order:

  1. Library provenance (data-library-id/data-library-component attributes) — the variant’s class set is re-tokenized using the same machinery as the D57a post-drop switcher, diffed against the currently stamped variant.
  2. Component-instance variants (component_id → the component’s prop_schema.variants) — each variant’s styles.base diffed against the currently active variant’s.
  3. Hand-authored variant-* layers already present in style_overrides via the Layers tab — listed as-is, never rewritten by this system.

Known v1 limitation, stated directly in the code: materialized variant layers are flat — base look only. The node model’s custom state layers don’t nest pseudo-sub-states, so a non-active variant’s hover styles are not carried into the materialized layer (the currently active/stamped variant keeps its full layered styling; only the alternatives lose nested states).

Materialization re-snapshots the currently stamped variant every time a “Switch variant” behavior is applied (ensureVariantStateLayers, called from the graph’s connect-gesture flow) — so if the source component’s variant is re-stamped later via LibraryVariantSection, the materialized layers self-heal on the next behavior edit rather than silently going stale.


Every write the graph and wires overlay make goes through this one file — the same contract PropertyEditorShell’s writeInteractionStack uses: optimistic Zustand update (useEditorStore.getState().updateNodeFields) followed by persistence through PropertyCommands.updateNodeInteractions (the standard command layer → the save-v2 sink — see CLAUDE.md’s Save path section; this is not a second write path). writeBehaviorStack writes either the base layer or a specific byBreakpoint[breakpointId] layer, matching the panel’s whole-stack-override semantics exactly.

Three operations, all built on that one internal function:

  • appendBehavior(nodeId, behavior, breakpointId)
  • removeBehaviorAt(nodeId, index, breakpointId)
  • replaceBehaviorAt(nodeId, index, behavior, breakpointId)

All three read the node’s currently active stack for that breakpoint first (activeBehaviorsAt), mutate it, and write the whole stack back — there is no partial/patch write of a single behavior.


The Motion panel (MotionPanel.tsx + lib/motion-scan.ts)

Section titled “The Motion panel (MotionPanel.tsx + lib/motion-scan.ts)”

A read-only site-wide audit surface, not an editing surface: it lists every element with motion on the currently open page(s) — CSS transitions, animating state changes, and interaction-driven animations/behaviors — and clicking a row opens that page and selects the element. Scanning is pure (lib/motion-scan.ts); the component itself is presentation only.

D83a (a same-day follow-up audit, per DECISIONS.md) fixed six real bugs in this panel, worth knowing because they describe what “correct” means here:

  1. It scanned all cached domTrees including closed pages → now scoped to pagesStore.openPageIds, and row-click routes through openPage so selection can never strand on an unmounted iframe.
  2. Interactions were unioned across the base layer and every byBreakpoint layer (double-counting, and showing shadowed rows that a breakpoint override had replaced) → now resolves per the editor’s current breakpoint, matching the published runtime’s own whole-stack-replacement semantics (the same rule §“Breakpoint awareness” above describes for the graph).
  3. It fell back to the render-inert styles column for phantom “motion” that the renderer never actually uses → dropped entirely.
  4. Motion linked through shared styles (style_id → component_styles) was invisible (a D51-era gap) → now resolved into the scan.
  5. Instant state swaps were mislabeled “animations.” A state layer now only counts as motion when a CSS transition actually covers one of its changed properties; the UI section is named “State changes” to reflect that distinction honestly.
  6. The conflict badge (a transition and a keyframe animation touching overlapping properties, where the animation can double-animate or override the transition) used a coarse property-set heuristic → now checks the real animated properties via animatedPropertiesOf (new in @matriz/site-render), parsed from the animation-preset catalog rather than guessed.

The Interactions rows adopt the shared wire-model edge labels (edgeActionLabel/edgeQualifierLabel) and the same isRuntimeExecutableAction inert badge as the graph and wires overlay — this is the “one enumeration, not three that can disagree” property stated at the top of this doc, made concrete: the Motion panel, the ⚡ wires overlay, and the full-page graph all describe the same edge the same way.


  • frontend/src/lib/behaviors/wire-model.ts — the pure edge model; read this first, everything else builds on it.
  • frontend/src/lib/behaviors/graph-model.ts — layout (columns/rows/extent).
  • frontend/src/lib/behaviors/behavior-write.ts — the one write path.
  • frontend/src/lib/behaviors/variant-states.ts + variant-state-commands.ts — variant materialization (pure logic + its async/store-reading half).
  • frontend/src/components/behavior-graph/BehaviorGraphOverlay.tsx — the full-page graph UI and its create/edit/delete gestures.
  • frontend/src/components/canvas/BehaviorWiresOverlay.tsx — the ⚡ canvas inspector, including the Selection/Page scope toggle.
  • frontend/src/components/sidebar-modes/MotionPanel.tsx + frontend/src/lib/motion-scan.ts — the read-only audit surface.
  • DECISIONS.md — search D83 and D83a for the original decision record this doc is derived from (not edited by this doc).