Files
initiative/specs/037-undo-redo/research.md
Lukas 17cc6ed72c Add undo/redo for all encounter actions
Memento-based undo/redo with full encounter snapshots. Undo stack
capped at 50 entries, persisted to localStorage. Triggered via
buttons in the top bar (inboard of turn navigation) and keyboard
shortcuts (Ctrl+Z / Ctrl+Shift+Z, Cmd on Mac, case-insensitive key
matching). Clear encounter resets both stacks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 23:30:33 +01:00

5.8 KiB

Research: Undo/Redo for Encounter Actions

Feature: 037-undo-redo Date: 2026-03-26

Decision 1: Undo/Redo Strategy — Memento (Snapshots) vs Command (Events)

Decision: Memento pattern — store full Encounter snapshots.

Rationale:

  • The Encounter type is small (tens of combatants, ~2-5 KB serialized). Storing 50 snapshots costs ~100-250 KB in memory and localStorage — negligible.
  • The codebase already serializes/deserializes full encounters for localStorage persistence. Reuse is straightforward.
  • All domain state transitions are pure functions returning new Encounter objects. Each action naturally produces a "before" snapshot (the current state) and an "after" snapshot (the result).
  • The command/event approach would require inverse operations for every domain function, including compound operations like initiative re-sorting. This complexity is not justified at encounter scale.
  • The existing event system captures previousValue/newValue pairs but lacks full combatant snapshots for structural changes (add/remove with initiative reordering). Extending events would be more work than snapshots for no practical benefit.

Alternatives considered:

  • Command pattern (inverse events): More memory-efficient per entry but significantly more complex. Requires implementing and testing inverse operations for all 18+ domain transitions. Rejected because the complexity outweighs the memory savings at encounter scale.
  • Hybrid (events for simple, snapshots for structural): Rejected because mixed strategies increase implementation and debugging complexity.

Decision 2: Stack Storage Location

Decision: Store undo/redo stacks in React state within useEncounter, persisted to localStorage via dedicated keys.

Rationale:

  • Matches the existing persistence pattern: encounter state lives in React state and is synced to localStorage via useEffect.
  • Using useState (not useRef) ensures React re-renders when stack emptiness changes, keeping button disabled states reactive.
  • Dedicated localStorage keys ("initiative:encounter:undo", "initiative:encounter:redo") avoid coupling stack persistence with encounter persistence.

Alternatives considered:

  • useRef for stacks: Would avoid re-renders on every push/pop, but then button disabled states wouldn't update reactively. Would need manual forceUpdate or separate boolean state — more complex for no clear benefit.
  • Single localStorage key with encounter: Rejected because it couples concerns and makes the encounter storage format backward-incompatible.

Decision 3: Snapshot Capture Point

Decision: Capture the pre-action encounter snapshot inside each action callback in useEncounter, before calling the use case.

Rationale:

  • Each action callback in useEncounter already calls makeStore() which accesses the current encounter via encounterRef.current. The snapshot is naturally available at this point.
  • Capturing at the hook level (not the use case level) keeps the domain and application layers unchanged — undo/redo is purely an adapter concern.
  • Failed actions (domain errors) should NOT push to the undo stack, so the capture must happen conditionally after confirming the action succeeded.

Alternatives considered:

  • Capture inside use cases: Would require changing the application layer API to return the pre-action state. Violates layered architecture — use cases shouldn't know about undo.
  • Capture via store wrapper: Could intercept store.save() to capture the previous state. Elegant but makes the flow harder to follow and debug. Rejected in favor of explicit capture.

Decision 4: Keyboard Shortcut Suppression Strategy

Decision: Check document.activeElement tag name and contentEditable attribute. Suppress encounter undo/redo when focus is on INPUT, TEXTAREA, or contentEditable elements.

Rationale:

  • Simple and reliable. The app uses standard HTML form elements for text input.
  • No contentEditable elements currently exist, but checking for them is defensive and low-cost.
  • The check happens in the keydown event handler before dispatching to undo/redo.

Alternatives considered:

  • Capture phase with stopPropagation: Overly complex for this use case.
  • Custom focus tracking via context: Would require every input to register/unregister. Too invasive.

Decision 5: UI Placement for Undo/Redo Buttons

Decision: Place undo/redo buttons in the TurnNavigation component (top bar), inboard of the turn step buttons. Turn navigation (Previous/Next Turn) stays as the outermost buttons; Undo/Redo sits between Previous Turn and the center info area.

Rationale:

  • TurnNavigation is the primary command bar for encounter-level actions (advance/retreat turn, clear encounter). Undo/redo are encounter-level actions.
  • Placing them in the top bar keeps them always visible when the encounter is active.
  • Turn navigation stays outermost because it's the most frequently used control during live combat. Undo/redo is secondary.

Alternatives considered:

  • ActionBar (bottom bar): Already crowded with combatant management controls (search, add, roll initiative). Undo/redo would be buried.
  • Floating buttons: Unconventional for this app's design language.

Decision 6: Clear Encounter and Undo Stack

Decision: Clearing the encounter resets both undo and redo stacks. Clear is not undoable.

Rationale:

  • Per spec (FR-010 and edge case). Clear is an intentionally destructive "reset" action. Making it undoable would create confusion about what "fresh start" means.
  • The existing clear encounter flow already has a confirmation dialog, providing sufficient protection against accidents.

Alternatives considered:

  • Make clear undoable: Would require keeping the pre-clear state in a special recovery slot. Adds complexity for a scenario already guarded by confirmation. Rejected per spec.