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 fromDECISIONS.md’s D83/D83a entries (cited, not edited by this doc).
Why it exists
Section titled “Why it exists”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.
The pure derivation layer: wire-model.ts
Section titled “The pure derivation layer: wire-model.ts”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.
BehaviorEdge
Section titled “BehaviorEdge”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) producessourceId === 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 withvariant-and the action isset_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.
Breakpoint awareness
Section titled “Breakpoint awareness”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.
Layout: graph-model.ts
Section titled “Layout: graph-model.ts”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.
The two visual surfaces
Section titled “The two visual surfaces”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 fromD:trigger-vocabulary-guards: the trigger list was once a subset of whatTRIGGER_SHORT_LABELScould 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:
- Library provenance (
data-library-id/data-library-componentattributes) — the variant’s class set is re-tokenized using the same machinery as the D57a post-drop switcher, diffed against the currently stamped variant. - Component-instance variants (
component_id→ the component’sprop_schema.variants) — each variant’sstyles.basediffed against the currently active variant’s. - Hand-authored
variant-*layers already present instyle_overridesvia 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.
The write path: behavior-write.ts
Section titled “The write path: behavior-write.ts”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:
- It scanned all cached
domTreesincluding closed pages → now scoped topagesStore.openPageIds, and row-click routes throughopenPageso selection can never strand on an unmounted iframe. - Interactions were unioned across the base layer and every
byBreakpointlayer (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). - It fell back to the render-inert
stylescolumn for phantom “motion” that the renderer never actually uses → dropped entirely. - Motion linked through shared styles (
style_id→component_styles) was invisible (a D51-era gap) → now resolved into the scan. - Instant state swaps were mislabeled “animations.” A state layer now
only counts as motion when a CSS
transitionactually covers one of its changed properties; the UI section is named “State changes” to reflect that distinction honestly. - 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.
Where to look next
Section titled “Where to look next”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— searchD83andD83afor the original decision record this doc is derived from (not edited by this doc).