Delete merged feature branches (005–037) that inflated the auto-increment counter in create-new-feature.sh, and renumber the undo-redo spec to follow the existing 001–005 sequence. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
5.8 KiB
Research: Undo/Redo for Encounter Actions
Feature: 006-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
Encountertype 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
Encounterobjects. 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/newValuepairs 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(notuseRef) 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
forceUpdateor 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
useEncounteralready callsmakeStore()which accesses the current encounter viaencounterRef.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
contentEditableelements currently exist, but checking for them is defensive and low-cost. - The check happens in the
keydownevent 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:
TurnNavigationis 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.