v0.2.1-alpha Migration Guide

Focus: This is the single upgrade path from the v0.1.x line. It covers a broad correctness-and-performance pass across the whole canvas — the measurement/layout lifecycle, edge routing, viewport culling, undo/redo history semantics, interaction and input fixes, and an observable fitViewplus the end-to-end animation subsystem overhaul that was staged as a separate v0.2.0-alpha and never tagged (folded in below). Most of the surface is additive; the behavior shifts are alpha-acceptable and documented with an escape hatch wherever one exists.

Each behavior change explains what changed, why, a before/after, and an escape hatch for code that needs the old behavior. New capabilities are listed near the end — they don't require migration, but are worth knowing about.

New FlowNode properties

All optional; existing node definitions continue to work unchanged.

  • fixedDimensions?: boolean — opt-in to inline style.height on leaf nodes AND opt out of ResizeObserver updates. Use for decorative or fixed-size nodes. Auto-promoted when the system drives a height: resize drag, compute output, and animation of dimensions.height all set this to true on the affected node.
  • resizeObserver?: boolean — default true. Set false to exclude a node from the shared ResizeObserver (e.g., annotation nodes or decorative overlays where measurement is noise).
  • minDimensions?: Partial<Dimensions> — lower bound for observed dimensions. Either width or height may be omitted ("no lower bound on that axis"). Applied by the ResizeObserver before updating node.dimensions.
  • maxDimensions?: Partial<Dimensions> — upper bound. Use Infinity for unbounded.

New canvas method

canvas.batch(fn)

Suspend layout reconciliation during fn, then run a single reconciliation pass after. Ref-counted (safe to nest — inner batches join the outer). Returns fn's return value. Reconciles on throw via try/finally so a throwing fn still reconciles before the error propagates.

// Adding 100 nodes that share a parent would previously trigger 100 layouts.
// With batch(), exactly one layout runs per affected parent.
$flow.batch(() => {
    $flow.addNodes(allNodes);
    $flow.addEdges(allEdges);
});

Access as canvas.batch(...) in static code, or $flow.batch(...) from Alpine scope inside <x-flow> (WireFlow) or x-data="flowCanvas(...)" elements.


Behavior changes — measurement & layout

Leaf nodes no longer get inline height

What changed. Before v0.2.1-alpha, every node received style.height = dimensions.height + 'px'. Leaf nodes with rich templated content (Alpine x-if / x-for templates, form controls) had their height locked to the initial (pre-render) measurement, clipping content that grew after templates populated.

Inline height is now applied only when one of three container signals is present — the node has childLayout (auto-layout container), the node is a parent (some other node references it via parentId), or fixedDimensions: true. Plain leaf nodes let content determine height; the shared ResizeObserver captures the natural rendered height back into node.dimensions for parent layout calculations.

{ id: 'note', position: { x: 0, y: 0 }, data: { label: 'long text…' } }
// BEFORE: inline height written → text clipped at the fixed height
// AFTER:  content drives leaf height

{ id: 'note', fixedDimensions: true, /* … */ }  // restore fixed sizing

Escape hatch. Add fixedDimensions: true to nodes whose explicit height must persist. Three system paths auto-promote it: resize drag, compute() output writing a height, and animation of dimensions.height.

Layout lifecycle is now reactive

  • Shared ResizeObserver: one ResizeObserver tracks every node element. When rendered dimensions change, node.dimensions updates automatically and the parent re-lays out. No more stale measurements.
  • Reactive childLayout: mutating node.childLayout.columns (or gap, padding, headerHeight, direction, stretch) triggers re-layout automatically — no manual layoutChildren(parentId).
  • addNodes auto-layouts parents: addNodes now re-lays out affected parent containers, consistent with removeNodes.

Escape hatch. If your code called canvas.layoutChildren(parentId) manually after mutations, you can remove those calls. Keep them only if they cover a path the new reactivity doesn't (rare).

Layout dedup — at most one layoutChildren per parent per frame

Multiple triggers in the same frame (add + observer callback) collapse to a single layoutChildren call per parent per animation frame. Frame-aligned, not time-based. Includes a cross-frame loop safety net: a parent laid out in >5 consecutive frames is suppressed with a console.warn until the next user mutation (addNodes, removeNodes) clears the counter.

Escape hatch. None — this is a strict de-duplication. If tests or tooling count layoutChildren calls and expected the old duplicate-call behavior, expect lower counts (assertions expecting 2× per mutation should now expect 1).

Dimensions are client-local, never synced

node.dimensions now updates reactively from the ResizeObserver. Different clients may measure different values (fonts, browser zoom, DPI). Dimensions are excluded from collab sync — they're local state, not part of the canonical document. This only matters if you're using Yjs collaboration.

Auto-layout excludes parented nodes

layout(), forceLayout(), treeLayout(), and elkLayout() now exclude nodes with parentId by default — their positions are managed by childLayout, not top-level auto-layout.

canvas.layout();
// BEFORE: children with parentId were re-laid-out by the top-level pass,
//         fighting their parent's childLayout
// AFTER:  skipped by layout()/forceLayout()/treeLayout()/elkLayout()

canvas.layout({ includeChildren: true });  // restore old behavior

Escape hatch. { includeChildren: true }.

Node-content centering moved from structural CSS to the default theme

What changed. structural.css no longer centers node content for every theme — only theme-default.css (and theme-flux.css) do. This keeps structural CSS opinion-free so custom themes control alignment.

/* BEFORE: structural.css centered node content for every theme */
/* AFTER:  only theme-default.css does — custom themes declare it: */
.my-theme .flow-node-content { text-align: center; }

Escape hatch. Custom themes that relied on the structural rule must add the text-align: center declaration themselves.


Behavior changes — history & undo/redo

Selection is no longer undoable

What changed. Selecting nodes (clicks, row selection) no longer pushes history snapshots.

// user clicks node A, clicks node B, presses Ctrl+Z
// BEFORE: each click pushed a byte-identical snapshot — undo "did nothing"
//         visibly, twice, before reaching the last real edit
// AFTER:  clicks capture nothing; undo goes straight to the last structural
//         change (move/add/remove/rename)

Escape hatch. None — selection was never a meaningful undo step. Structural operations (move/add/remove/rename) still capture as before.

Identical consecutive history states are deduped

canvas.updateNode('a', { position: { x: 100, y: 50 } });  // already there
// BEFORE: pushed a duplicate-of-top snapshot → one wasted undo step
// AFTER:  snapshot equals stack top → no undo step added

Snapshots are stored as JSON strings and compared against the current stack top.

Node drag captures history on commit, not pointer-down

// user presses pointer on a node and releases without moving
// BEFORE: capture fired at pointer-down → click-to-select = undo entry
// AFTER:  capture fires at drag commit, only if the node moved

Arrow-key nudge: one capture per physical keypress

// user holds ArrowRight — OS auto-repeat fires ~30 keydowns
// BEFORE: 30 undo entries; undoing the nudge = 30 × Ctrl+Z
// AFTER:  1 entry per physical press; empty selection captures nothing

Undo/redo now emit a restore event

What changed. undo() / redo() now dispatch the flow-restore DOM event (config callback onRestore), which previously fired only for fromObject(). External observers that synced on fromObject but drifted during undo/redo now stay in sync.

el.addEventListener('flow-restore', (e) => { /* sync sidebar, server… */ });

// BEFORE: fired only for fromObject() — undo()/redo() were silent
// AFTER:  also fires for undo()/redo(), with
//         e.detail = { nodes, edges, origin: 'undo' | 'redo' }

Behavior changes — change & restore event payloads

restore payload field: sourceorigin

What changed. A dev-only interim source tag (never shipped in a release) becomes public for the first time as origin. No released user has ever seen source.

el.addEventListener('flow-restore', (e) => {
    // dev-only interim (never released): e.detail.source → 'undo' | 'redo';
    //                                    fromObject() carried no tag at all
    // SHIPS AS: e.detail.origin → 'undo' | 'redo' | 'load'
    //           ('load' = fromObject / $reset / $clear)
});

nodes-change / edges-change gained origin

What changed. Both events now carry an origin discriminator so you can tell a user drop from your own API write. Runtime-additive — listeners ignoring the new key are unaffected.

el.addEventListener('flow-nodes-change', (e) => {
    // BEFORE: e.detail = { type: 'add', nodes: [...] } — no way to tell a user
    //         drop from your own API write
    // AFTER:  e.detail = { type: 'add', nodes: [...],
    //                      origin: 'drop' | 'paste' | 'api' | 'load' }
    if (e.detail.origin !== 'api') saveToServer(e.detail.nodes);
});

canvas.addNodes(nodes, { source: 'load' });  // mutators can stamp it

Escape hatch. None needed. WireFlow handlers receive origin automatically through the wire bridge.


Behavior changes — viewport

canvas.viewport settles on the next animation frame

What changed. Zoom/pan side-effects coalesce to one flush per requestAnimationFrame. Coordinate helpers (screenToFlowPosition, flowToScreenPosition) read the live value internally, and gesture-end still commits synchronously — so this is mostly test-facing.

canvas.setViewport({ x: 0, y: 0, zoom: 2 });
// BEFORE: canvas.viewport.zoom === 2 immediately
// AFTER:  canvas.viewport.zoom still holds the old value…
await new Promise(requestAnimationFrame);
canvas.viewport.zoom === 2;                  // …settles next frame

Escape hatch. None — read the live viewport through the coordinate helpers, or await one frame before asserting on canvas.viewport.


Behavior changes — edge routing

Avoidant edges: rounded corners replace the Catmull-Rom spline

What changed. Every avoidant edge's d string changes. Straight runs are kept and corners are rounded with bounded fillets that cannot overshoot the way the old spline bulged past corners. Catmull-Rom remains only for editable edges.

// BEFORE — one smooth spline through the waypoints (overshoots corners):
M219,79.5 C352,79.5 486,63 620,120 C640,146 655,300 660,530 

// AFTER — straight runs + in-corner fillets (hugs the route):
M219,79.5 L620,79.5 C640,79.5 660,99.5 660,119.5 L660,941
  C660,946.25 665.25,951.5 670.5,951.5 L681,951.5

Escape hatch. None — the fillets are strictly better geometry. Snapshot tests that pinned exact d strings for avoidant edges must be re-baselined.

Detours tie-break deterministically

Length is never traded; among equal-length candidates the router now prefers fewest corners / closest-to-corridor, so specific waypoints may sit elsewhere than on v0.1.2. Always-on, no knob.

Obstacles burying a route endpoint are excluded

What changed. When a node lands on top of an edge's endpoint handle (easy to hit after scramble()), that node is dropped from the edge's obstacle set so the edge still routes.

// scramble() lands node "sessions" on top of users' right-side handle;
// five edges target that handle
// BEFORE: routing failed for those edges → straight bezier through everything,
//         stuck until the connected node was dragged
// AFTER:  the burying node is dropped from those edges' obstacle set — they
//         route around everything else immediately

Obstacle geometry is non-reactive

A programmatic node move that doesn't bump the layout tick won't re-route dependent edges until the next commit point; interactive gestures re-route as before.

Hidden nodes are no longer routing obstacles

canvas.updateNode('big-group', { hidden: true });
// BEFORE: edges still detoured around the invisible node's rect
// AFTER:  edges may route straight through where it sits

Drag-simplified edges default on

What changed. During a node drag, incident avoidant/orthogonal edges render a simplified bezier for the duration of the gesture, snapping back to the routed path on drop — instead of re-running full obstacle pathfinding on every pointermove.

// BEFORE: full obstacle pathfinding re-ran for incident edges on every
//         pointermove during a node drag
// AFTER:  incident avoidant/orthogonal edges render a simplified bezier during
//         the gesture, snap back to routed on drop

flowCanvas({ avoidantSimplifyOnDrag: false });  // restore old behavior

Escape hatch. avoidantSimplifyOnDrag: false.

animate() settle re-runs edge geometry

canvas.animate({ nodes: layoutTargets }, { duration: 600 });
// BEFORE: after settling, schema edges stayed detached at node centres with
//         avoidant routing lost (worst on a no-op move)
// AFTER:  settle re-measures + rebuilds obstacles once — edges snap back to
//         real handles and routes

Behavior changes — viewport culling

viewportCulling default false'auto'

What changed. Culling now turns on automatically once the node count reaches the auto threshold (150), toggling display:none on off-screen nodes and edges.

flowCanvas({ nodes: twoHundredNodes });
// BEFORE: all 200 nodes rendered regardless of viewport
// AFTER:  ≥150 nodes → off-screen nodes/edges get display:none

flowCanvas({ viewportCulling: false });     // always render everything
flowCanvas({ viewportCulling: true });      // cull at any node count
flowCanvas({ cullingAutoThreshold: 500 });  // raise the auto trip point

Escape hatch. viewportCulling: false.

Edges are culled too

An edge's SVG hides when both endpoints are off-screen and its last-routed corridor doesn't intersect the viewport. Edges without a recorded corridor stay visible.


Behavior changes — canvas lifecycle

destroy() now actually runs

What changed. A mixin-shadowing bug had replaced the canvas's own destroy() (~140 lines dead since 9855d68). It now runs: fullscreen exits, the flow store unregisters, and wire-bridge listeners and global observers dispose.

flowCanvas({
  onDestroy(detail, ctx) {
    cleanupMyIntegration();
    // BEFORE: never invoked — destroy() was silently shadowed
    // AFTER:  runs on teardown. If you wrote this handler months ago, it
    //         executes for the FIRST TIME in this release.
  },
});

A caller-supplied collab.provider is not destroyed — the app owns it.

Escape hatch. None — the teardown was always supposed to run. Audit any long-dormant onDestroy handler, since it fires for the first time now.

Handle pointerdown is delegated in the capture phase

What changed. One capture-phase listener per canvas now claims handle pointerdown at the viewport (~5,000 per-handle bubble-phase listeners → 1). Markup rendered inside a source handle no longer receives its own pointerdown.

// markup rendered INSIDE a source handle:
badgeInsideHandle.addEventListener('pointerdown', openMenu);
// BEFORE: openMenu fired (per-handle listeners, bubble phase)
// AFTER:  never fires — the capture-phase listener claims the event first

// migration: attach outside the handle, or:
flowCanvas({ delegatedHandleEvents: false });

Escape hatch. Attach the listener outside the handle element, or delegatedHandleEvents: false.


Behavior changes — interaction & input

Double-click zoom can be a toggle

What changed. zoomOnDoubleClick accepts a mode. Default behavior is unchanged ('step'); the toggle is opt-in.

// BEFORE: zoomOnDoubleClick: boolean — dblclick = one d3 zoom step
// AFTER:  'step' (default, unchanged) | 'toggle' | false
flowCanvas({ zoomOnDoubleClick: 'toggle', dblClickZoomLevel: 2 });
// dblclick № 1 → jump to zoom 2 about the cursor
// dblclick № 2 → restore the exact prior viewport

Wheel zoom now works while the pointer is over a node

What changed. .nopan gates panning only; wheel is exempt and gated separately by .nowheel.

// user scrolls / pinches while the cursor sits on a node (.nopan)
// BEFORE: nothing happened — .nopan blocked EVERY gesture incl. wheel,
//         which on a dense canvas is most of the surface
// AFTER:  zooms. Panning is still gated by nopan; wheel is gated separately:
//         <div class="nowheel">scrollable list inside a node</div>

Keyboard shortcuts no longer hijack contenteditable

What changed. isEditableTarget() now honors el.isContentEditable, not just INPUT/TEXTAREA.

// node body contains: <div contenteditable>Quarterly notes</div>
// user is typing and presses Backspace / arrows / Cmd+Z
// BEFORE: tagName check (INPUT/TEXTAREA only) missed it → Backspace DELETED
//         THE NODE, arrows moved it, Cmd+Z hit canvas history
// AFTER:  keys edit the text. Enter/Space activation is gated to the node
//         wrapper, so nested buttons/inputs behave natively.

Drops over panels/controls/minimap/devtools no longer add nodes

// user drags from a palette, releases over the minimap
// BEFORE: a node was silently added underneath the overlay
// AFTER:  the drop cancels; canvas/node drops are unchanged

Applies to .flow-panel, .flow-controls, .flow-minimap, and any .canvas-overlay.


Behavior changes — schema, export, fitView

schemaHandleGeometry defaults to 'auto'

What changed. Schema-edge endpoints are derived arithmetically from node position/dimensions/fields (zero getBoundingClientRect), parity-tested byte-identical, with automatic DOM fallback for rotated/culled/non-uniform cases.

// BEFORE: every schema edge measured its row handles from the DOM
// AFTER:  computed from node position/dimensions/fields (2.3–3.8× faster)
flowCanvas({ schemaHandleGeometry: 'dom' });  // legacy path, if needed

Escape hatch. schemaHandleGeometry: 'dom'.

toImage() now includes edges

What changed. Edges used to rasterize invisible (their stylesheet paint was dropped). They now render with their computed stroke, markers, and dash.

const png = await canvas.toImage();
// BEFORE: edges rasterized INVISIBLE (stylesheet paint dropped)
// AFTER:  edges render with their computed stroke/markers/dash

// new options (defaults preserve the old 1× PNG):
await canvas.toImage({ scale: 2 });
await canvas.toImage({ format: 'jpeg', quality: 0.9 });
await canvas.toImage({ format: 'svg' });

fitView() returns Promise<boolean>

What changed. fitView() used to return void and, if nodes were unmeasured, silently give up after ten frames — unobservable. It now returns a promise that resolves whether the fit actually ran.

// BEFORE: fitView(): void — unmeasured nodes → silent give-up after 10 frames
// AFTER:
const fitted = await canvas.fitView();
if (!fitted) console.warn('nodes still unmeasured after retry budget');

// runtime-non-breaking: ignoring the return works exactly as before

Escape hatch. None needed — ignoring the return value is unchanged. TypeScript callers who annotated : void see a compile note only.

Connect-drag snapshots handle state at drag start

Handle connectability/visibility is indexed once per connect gesture (163× faster snap); mid-gesture changes aren't re-measured until the next drag. Identical results for static layouts.


Type-level change

SchemaMetrics gained required fields

What changed. Hand-constructing the exported SchemaMetrics type no longer compiles — it gained required geometry fields. Reading canvas._schemaMetrics is unaffected and runtime behavior is unchanged. Note rowHeight is now a row stride (row top → next row top), not the visual row height.

// BEFORE: compiled
const m: SchemaMetrics = { headerHeight: 40, rowHeight: 28, /* … */ };

// AFTER: fails typecheck — missing required fields. Supply the new geometry:
const m: SchemaMetrics = {
  headerHeight: 40,
  rowHeight: 28,            // now the STRIDE: row top → next row top
  rowHeightLast: 28,
  insetLeft: 0, insetRight: 0, insetTop: 0, insetBottom: 0,
  handleOffsetY: 14, handleOffsetYLast: 14,
  handleWidth: 8, handleHeight: 8,
  /* …existing fields… */
};

Escape hatch. None — only affects code that constructs the type by hand (rare). Consumers that read the value are unaffected.

runState (D2)

New node.runState property

Nodes now accept an optional runState field that drives visual state without any per-project CSS boilerplate:

{ id: 'step-1', position: { x: 0, y: 0 }, data: { label: 'Step 1' }, runState: 'running' }

Valid values: 'pending' | 'running' | 'completed' | 'failed' | 'skipped'

The 'pending' value (or absent runState) is the baseline — no class applied, no visual change.

Auto-applied CSS classes

When runState is set, the node directive automatically adds the matching class to the node element:

runState CSS class
running .flow-node-running
completed .flow-node-completed
failed .flow-node-failed
skipped .flow-node-skipped

Classes are toggled reactively — setting node.runState = 'completed' instantly swaps .flow-node-running for .flow-node-completed.

Theme provides default animations

Both shipped themes (theme-default.css, theme-flux.css) include default visual treatments out of the box:

  • running — violet border + repeating pulse ring
  • completed — teal border + one-shot background flash
  • failed — red border + repeating pulse ring
  • skipped — dimmed (0.45 opacity) + slight grayscale

All colors are overridable via CSS variables on .flow-container:

.flow-container {
    --flow-node-running-border-color: #8b5cf6;
    --flow-node-running-pulse-color: rgba(139, 92, 246, 0.55);
    --flow-node-completed-border-color: #14b8a6;
    --flow-node-completed-flash-color: rgba(20, 184, 166, 0.12);
    --flow-node-failed-border-color: #ef4444;
    --flow-node-failed-pulse-color: rgba(239, 68, 68, 0.55);
    --flow-node-skipped-opacity: 0.45;
}

$flow.setNodeState() + $flow.resetStates()

Two canvas helpers manage runState from Alpine scope:

// Set state on one or more nodes
$flow.setNodeState(['step-1', 'step-2'], 'running');
$flow.setNodeState('step-3', 'completed');

// Clear all runState values (reset to pending/baseline)
$flow.resetStates();

Wire-bridge commands

WireFlow consumers dispatch runState changes from PHP — no client JS required:

// Set state server-side
$this->flowSetNodeState(['step-1', 'step-2'], 'running');
$this->flowSetNodeState('step-3', 'completed');

// Reset all
$this->flowResetStates();

Both methods keep the server-side $nodes array in sync before dispatching, so Livewire re-renders reflect the current runState without manual array manipulation.

Not yet reachable (planned)

  • patchConfig does not accept a nodes key — it's canvas-level config only. Server-side node/childLayout mutation uses direct reactive assignment (via the wire-bridge flow:updateNode command or $wire.entangle).

Drop zone enhancements

Four related improvements that unblock builder-style consumers (e.g. AlpineForm) who previously had to reimplement most of the drop zone behavior themselves.

New dropMimeTypes config

flowCanvas({
    dropMimeTypes: ['application/alpineflow', 'application/alpineform-field'],
    onDrop({ data, mimeType, position, targetNode }) { /* ... */ },
})

The default is ['application/alpineflow'], so existing consumers are unaffected. Pass an array of accepted MIME types; the first matching MIME is reported as mimeType in the onDrop detail.

onDrop detail now includes mimeType

// Before
onDrop({ data, position, targetNode }) { /* ... */ }

// After (mimeType added — existing destructuring still works)
onDrop({ data, mimeType, position, targetNode }) { /* ... */ }

targetNode is now the deepest container

targetNode now returns the deepest FlowNode under the cursor instead of the outermost. For nested containers (page → section → column), a drop on the column reports the column. If you need the outermost ancestor, traverse node.parentId upward:

function outermost(node, flow) {
    let n = node;
    while (n?.parentId) n = flow.getNode(n.parentId);
    return n;
}

.flow-canvas-drag-over CSS class

The canvas container element receives the .flow-canvas-drag-over class during a valid drag-over (i.e., when at least one MIME type in dropMimeTypes is present). The class is removed on dragleave and on drop. Use it for drag-over feedback styling:

.flow-container.flow-canvas-drag-over {
    outline: 2px dashed var(--color-accent);
}

New canvas.getNodeAtPoint(clientX, clientY)

Returns the deepest FlowNode at the given client coordinates, or null if no node is found. Uses the same elementsFromPoint + deepest-container logic as the drop zone. Useful for context menus, tooltips, and custom pointer interactions beyond the built-in drop zone.

canvas.addEventListener('contextmenu', (e) => {
    const node = $flow.getNodeAtPoint(e.clientX, e.clientY);
    if (node) openContextMenu(node, e);
});

Workflow addon

New optional addon: @getartisanflow/alpineflow/workflow. Provides $flow.run() for structured workflow execution. See the Workflow addon reference for the complete API.

Key features:

  • $flow.run(startId, handlers, options) walks the graph with state transitions, handlers, and pacing
  • flow-condition nodes with declarative condition evaluation (10 operators, dot-path field access)
  • flow-wait nodes for pacing
  • Edge state CSS classes (.flow-edge-entering, .flow-edge-completed, .flow-edge-taken, .flow-edge-untaken)
  • Reactive $flow.executionLog with structured events
  • FlowRunHandle with pause/resume/stop controls

Requires: Alpine.plugin(AlpineFlowWorkflow) after Alpine.plugin(AlpineFlow).

Animation subsystem overhaul

The animation subsystem was overhauled end-to-end — transactions, tagged groups, state-aware cancellation, physics motion, record & replay, and a new beam renderer. This work was originally staged as a separate v0.2.0-alpha that was never tagged, so it reaches you as part of this single v0.1.x → v0.2.1-alpha upgrade. Most of it is additive; the behavior changes below are worth auditing.

Breaking changes

Beam renderer duration now includes follow-through

What changed. The beam particle renderer used to interpret duration as "time for the head to reach the target" — the beam vanished the instant it arrived. It now interprets duration as the total beam lifetime: the tail continues past the target after the head arrives, fading off naturally. onComplete fires after the tail exits, not when the head arrives.

Why. The old behavior looked abrupt on curved paths — the beam would disappear mid-stride. Follow-through is the visually-correct default for tracer/laser-style effects and matches Framer Motion conventions.

Before:

$flow.sendParticle('e1', {
    renderer: 'beam',
    duration: 1200,
    onComplete: () => triggerHitEffect(), // fired at head arrival
});

After (if you rely on onComplete firing at head-arrival time):

$flow.sendParticle('e1', {
    renderer: 'beam',
    duration: 1200,
    followThrough: false,  // restore pre-v0.2.0 behavior
    onComplete: () => triggerHitEffect(),
});

Escape hatch. followThrough: false on ParticleOptions restores the old head-arrival semantics. See Particles → Beam → Follow-through.


loop: 'reverse' renamed to loop: 'ping-pong'

What changed. AnimateOptions.loop accepts true (loop forever, restart from start) or a string form for bounce-back behavior. The string form was renamed 'reverse''ping-pong' for clarity.

Why. 'reverse' was ambiguous — readers expected it meant "play backward once," not "bounce back and forth forever." 'ping-pong' matches common animation-library convention.

Before:

$flow.animate(targets, { duration: 800, loop: 'reverse' });

After:

$flow.animate(targets, { duration: 800, loop: 'ping-pong' });

Escape hatch. 'reverse' still works as an alias for backwards compatibility. No urgent action needed — both forms produce identical behavior. Over time, move toward 'ping-pong' in new code.


handle.reverse() on finished handles now plays backward

What changed. Calling reverse() on a FlowAnimationHandle after the animation had already completed used to be a silent no-op. It now revives the handle and plays from target back to start.

Why. The no-op behavior was a trap — callers wrote "rewind" UIs assuming reverse() did something on completed animations. It didn't, and the bug surfaced only when users actually completed an animation and clicked reverse. Playing backward is the natural semantic.

Before:

const handle = $flow.animate(targets, { duration: 500 });
await handle.finished;
handle.reverse();  // no-op, animation stayed at target

After:

const handle = $flow.animate(targets, { duration: 500 });
await handle.finished;
handle.reverse();  // now plays from target back to start

Escape hatch. No revert flag — this is strictly a bug fix. If you relied on the old no-op to short-circuit, add an explicit if (handle.isFinished) return; guard before calling reverse().

See Direction state machine.


onComplete no longer fires when an animation is superseded

What changed. When you call $flow.animate() on keys that are already being animated by a prior call, the existing animation is stopped to let the new one take over (the "blend/compose" behavior). That stop now uses a new internal 'superseded' stop mode that does not fire the superseded animation's onComplete. Previously it did.

Why. Firing onComplete for an animation that didn't actually complete was misleading. It caused downstream side effects (mark-as-done, sound effects, analytics pings) to fire for animations that were interrupted, not finished.

Before:

// onComplete fires even though animation 1 was interrupted by animation 2
$flow.animate({ nodes: { a: { x: 100 } } }, { duration: 1000, onComplete: ping });
setTimeout(() => {
    $flow.animate({ nodes: { a: { x: 500 } } }, { duration: 1000 });
    // `ping` fires here, even though animation 1 was superseded
}, 200);

After:

// Same code — animation 1's `onComplete` does NOT fire. Only animation 2's will.

Escape hatch. None needed — this is the correct behavior. If you want to know when an animation is superseded, check handle.isFinished after starting a new one on the same keys, or track handles manually via $flow.getHandles({ tag }).


structuredClonesafeClone with JSON fallback

What changed. Internally, the animator, timeline, and recorder used structuredClone() to snapshot state. structuredClone throws DataCloneError on values containing functions or Alpine reactive proxies — which caused silent failures in real canvas usage where a node's data had callbacks or was wrapped by Alpine's reactivity system. We now use a safeClone helper that falls back to JSON roundtrip (stripping functions) with a one-time console.warn.

Why. $flow.record() and $flow.timeline() both threw DataCloneError on any canvas using Alpine reactive data with function callbacks. The fallback makes the common case work; the console.warn surfaces that functions were stripped.

Before: Code using $flow.record() or $flow.timeline() with node/edge data containing functions would throw DataCloneError.

After: Same code works. First time safeClone falls back (per session), a warning logs:

[AlpineFlow] Cloning fell back to JSON roundtrip because structuredClone
rejected the input. Any functions or non-cloneable values in the data
were stripped. Consider replacing these with serializable primitives.

Escape hatch. No action required. If you see the warning and want to eliminate it, replace callbacks in node.data / edge.data with string keys that resolve to registered handlers elsewhere.


ParticleHandle.getCurrentPosition() reads from internal state

What changed. ParticleHandle.getCurrentPosition() used to parse the SVG element's DOM attributes (cx/cy for circles, transform for orbs/beams) to derive position. It now returns a cached position updated by the particle engine each frame.

Why. DOM parsing was fragile — it broke for custom renderers that didn't use the expected attributes, and it returned null during the brief window after destroy() removed the element. Internal state is source-of-truth.

Before / After. No code changes needed — the return type and behavior are unchanged. The fix is purely an implementation swap. The only observable change: custom renderers that relied on getCurrentPosition() returning null immediately after destruction may now see the final position for one more frame.

Escape hatch. None needed. If you have a custom renderer that writes position to a non-standard DOM attribute and expected getCurrentPosition() to parse it, switch to tracking position in the renderer's own state.


bounceStiffness/bounceDamping recomputation on inertia motion

What changed. The inertia motion type's bounceStiffness and bounceDamping options were normalized internally. Previously they fed directly into the integrator with surprising scaling. They now use a bounciness × (1 - damping/100) model matching Framer Motion's semantics.

Why. Configs that looked reasonable (bounceStiffness: 300, bounceDamping: 30) produced wildly different bounce behavior depending on the initial velocity. The new model behaves predictably across velocities.

Before: Existing inertia configs may bounce differently than before (usually less, since damping now has a tamping effect on stiffness).

After: If your previous config felt right, bump bounceStiffness up 20-40% and/or lower bounceDamping to recover similar visual behavior.

Escape hatch. None — the old semantics were inconsistent. Tune the new config empirically. Starting point: { bounceStiffness: 300, bounceDamping: 30 } for noticeable-but-quick bouncing.

See Physics → Inertia.


FlowTimeline constructor now takes (canvas, engine?)

What changed. Direct construction of FlowTimeline (not via $flow.timeline()) required a different signature before. new FlowTimeline(canvas, engine?) is the current form — an explicit canvas reference and an optional animation engine override.

Why. Previously the constructor had an implicit engine lookup via global state, which made it hard to run multiple isolated animator instances (needed for tests and the new record/replay system).

Before:

const tl = new FlowTimeline(canvas);

After:

// Typical usage — no change, prefer the factory:
const tl = $flow.timeline();

// Direct construction (rare, usually for testing):
const tl = new FlowTimeline(canvas, customEngine);

Escape hatch. Use $flow.timeline() instead of new FlowTimeline(...). The factory wires the engine automatically and is the supported entry point. Direct construction is considered internal.


FlowTimeline<TContext> generic parameter

What changed. The FlowTimeline class now takes an optional TContext generic for typed context objects. Default is Record<string, any>, so no migration is needed for JS users or TS users who don't care about context typing.

Why. Context-aware timelines (.setContext({...}).step((ctx) => ...)) are much more useful with autocomplete and type-checking on ctx.context.

Before:

const tl = $flow.timeline();
tl.setContext({ winner: '' });
tl.step((ctx) => {
    ctx.context.winner  // untyped, no autocomplete
    return { nodes: [ctx.context.winner], position: { x: 400 }, duration: 300 };
});

After (opt-in typing):

interface MyCtx { winner: string; }
const tl = $flow.timeline<MyCtx>();
tl.setContext({ winner: '' });
tl.step((ctx) => {
    ctx.context.winner  // typed as string, autocompletes
    return { nodes: [ctx.context.winner], position: { x: 400 }, duration: 300 };
});

Escape hatch. None needed. Omitting the generic defaults to Record<string, any> and behaves exactly as before.

See Timeline → Context.


New animation capabilities (no migration needed)

None of these require changes to existing code — they're all new animation surface you can opt into.

Stop modes

handle.stop({ mode }) and $flow.cancelAll(filter, { mode }) now accept three modes:

  • 'jump-end' (default) — snap to target values
  • 'rollback' — revert to values captured when the animation started
  • 'freeze' — leave at current interpolated value

See Animate → Stop modes.

Transactions

Group several animations with rollback-as-a-unit behavior:

const tx = $flow.transaction(async () => { /* multiple animate calls */ });
tx.rollback();   // stops all tracked handles and reverts touched properties

See Animate → Transactions.

Tagged groups

Tag animations for bulk control:

const g = $flow.group('ambient');
g.animate(targets, options);
g.cancelAll({ mode: 'rollback' });
g.pauseAll();
g.resumeAll();

See Animate → Groups.

State-aware cancellation (while: predicate)

Auto-terminate an animation when a predicate flips to false:

$flow.animate(targets, {
    while: () => userStillHovering,
    whileStopMode: 'freeze',
});

See Animate → State-aware cancellation.

Direction state machine

FlowAnimationHandle now exposes direction, play(), playForward(), playBackward(), restart(), isFinished, and currentValue (a Map of per-key interpolated values).

See Animate → Direction state machine.

Physics motion

Four new motion types via motion: option (replaces duration+easing when set):

  • spring — natural settle behavior with 5 presets
  • decay — coasting/deceleration from initial velocity
  • inertia — decay with bounds, bounceback, and snapTo
  • keyframes — multi-waypoint tours with optional per-segment easing

See Physics.

Record & replay

Capture any sequence of canvas API calls and replay it with scrub, speed, reverse, loop, and thumbnail generation:

const recording = await $flow.record(async () => { /* ... */ });
const handle = $flow.replay(recording, { paused: true });
handle.scrubTo(1500);

See Record & Replay.

New particle renderer: beam (with gradient + follow-through)

The beam particle renderer follows the backing SVG path's curvature (no more clipping past corners), supports multi-stop linear gradients painted tail→head, and defaults to natural follow-through behavior. See the beam breaking change above and Particles → Beam renderer.

Expanded particle firing methods

sendParticleAlongPath, sendParticleBetween, sendParticleBurst, sendConverging all now expose particle handles and complete the emission API. See Particles → Firing methods.


Known gaps in record & replay

A handful of record/replay items remain tracked as known gaps (still open as of v0.2.1-alpha):

  • User drag capture in recordings — drags mutate node.position directly; currently not captured. Fix is a per-frame interaction sampler.
  • Direct viewport methods in recordings$flow.setViewport/fitView/zoomIn/setCenter/panBy bypass the recorder. Same sampler would cover these.
  • Particle burst variant callback replay — the per-index callback is stripped at capture time; all particles in a replayed burst share base options.
  • recorder.stop() for rolling buffers$flow.record(fn) requires fn to resolve naturally before the Recording is available. Planned: AbortSignal support or explicit stop.

See Record & Replay → Known gaps.

New capabilities (no migration needed)

None of these require changes to existing code — they're all new surface you can opt into. Full details live in the reference docs and the CHANGELOG.

  • replaceNodes() / setNodes() — atomic whole-graph replace on the identity-preserving path; resolves once the new nodes are measured, so an immediate fitView() fits. Server-callable via flow:replaceNodes / flow:setNodes.
  • data-flow-target — out-of-canvas directive targeting via a shared canvas resolver, so x-flow-action, x-flow-snapshot, x-flow-edge-toolbar, and the rest work from a toolbar/sidebar.
  • Config callbacks receive the canvas context as an optional second argument — onConnect(detail, ctx), onDrop(detail, ctx), etc.
  • interactive config — start a canvas locked; a master overlay on top of pannable/zoomable.
  • Schema-addon methods typed on CanvasContext — the eleven addField / schemaToJSON / … methods are now typed when you import @getartisanflow/alpineflow/schema (module augmentation).
  • ELK rectpacking + aspectRatio + raw layoutOptions escape hatch on the ELK wrapper.
  • avoidantCrossingReduction + setCrossingReduction() — opt-in corridor lanes for edges sharing a gap.
  • avoidantEndpointSpread — opt-in canvas/per-node endpoint fanning.
  • noWheelClassName; dblClickZoomLevel; ChangeOrigin type export.
  • Custom edge generators receive the edge as a second argument.
  • Schema render hooks — row/node class reconcilers plus imperative decorators.
  • schemaHandleGeometry escape hatch ('auto' | 'dom').

Looking ahead to v0.3.0-alpha

Two structural moves are planned for the next chapter. No action is required this release — they're flagged here so alpha users aren't surprised.

  • The Livewire wire-bridge moves from alpineflow to wireflow. The auto-registered bridge leaves alpineflow core; a generic command-dispatch hook replaces auto-registration, and wireflow ships the adapter. Raw alpineflow users shed dead Livewire code; wireflow users see no change.
  • The animation engine becomes @getartisanflow/alpineflow/animate. Timeline, particles, recorder, and physics move to an opt-in subpath addon (mirroring the existing /schema and /workflow addons), shrinking the core bundle substantially. A single migration guide will cover both moves.

Installation / upgrade

npm install @getartisanflow/alpineflow@^0.2.1-alpha
npm run build

For WireFlow consumers, a follow-up WireFlow bump pulls the new dist — no code change if you accept the default wireflow:install output.

Questions or issues?

File an issue at github.com/getartisanflow/alpineflow/issues.