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>
83 lines
5.8 KiB
Markdown
83 lines
5.8 KiB
Markdown
# 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.
|