Status: Accepted (2026-07-17)
Context
Undo was re-implemented once per Studio surface:
- The Design-System / Library / Sorgente surfaces got their history from
useStudioTree— a localuseStatetree plus ahistoryRefstack,undodrivingsetLocal. - The Workflow surface got a separate copy in
WorkflowCompositionArtboard— its ownhistoryRef, its ownundo, but the tree lived in the parent (useWorkflowArtboards, ADR 0066) andundoround-tripped through the parent'ssaveTree.
studio-bus.undoActiveTree() only delegated to whichever surface was active —
it owned nothing. So the two copies drifted. The DS copy (local state) worked;
the Workflow copy did not: undo popped the ref and reverted the bus's
treeRef, but the parent-owned render prop never updated, so ⌘Z visibly did
nothing on Workflow. This is the exact failure the user hit moving from DS to
Workflow — "worked on DS, fell apart at the seams on Workflow."
The same shape (a feature re-implemented per surface, then diverging) is a standing risk for every cross-surface capability (selection, delete, reorder).
Decision
Extract undo/redo into one standalone module, @arno/stepback, that owns
the history AND the authoritative value, and have every surface plug into it
instead of re-rolling a stack.
createStepBack<T>— a pure, framework-agnostic controller:current+ apast(undo) stack + afuture(redo) stack. Generic overT, so ANY back/forward mechanic uses it, not just the Studio tree.useStepBack<T>— the React binding. It ownsvaluein React state, so a surface that renders fromvaluecan never desync its bus handle from its render. The surface suppliesonCommit(persistence) only; StepBack never persists itself.replacere-seeds from outside (remote sync, context switch) without recording history.useUndoRedoHotkeys— the ⌘Z / ⌘⇧Z (and Ctrl+Y) bindings, mounted once inStudioDndShell, routed toundoActiveTree/redoActiveTreeon the bus.- The bus gains
redo?on the handle andredoActiveTree().
@arno/stepback depends only on react — nothing Studio-specific. It ships
with Layers by default (they mount together) but relocates freely: the
"attachment" is the shell that co-mounts Layers wiring the hotkeys to the bus.
Consequences
- The Workflow undo bug is fixed by construction. Both surfaces now render from StepBack's value; there is no parent round-trip to lose.
- Redo exists for the first time, everywhere, on ⌘⇧Z / Ctrl+Y.
- Both per-surface history copies are deleted.
useStudioTreeandWorkflowCompositionArtboardkeep only their genuinely-surface-specific parts (Sorgente debounce/remote-sync; parent-owned persistence) and delegate history to StepBack. - Establishes the module pattern for future extractions (a feature = an
@arno/*package with explicit deps + a mount interface; e.g. selection already moved to@arno/selection-frame).
Alternatives considered
- Put history in
@arno/ponte(the bus core). Rejected as the primary home because the user's model is "a module that travels with Layers and is relocatable"; a standalone package expresses that better and keeps ponte lean. The bus still exposes theundo/redobridges — it delegates to the surface's StepBack, it does not own the stack. - Fix only the Workflow copy. Rejected — it leaves the duplication that caused the divergence, guaranteeing the next surface repeats it.