From 613bb7006545d08c3ee0f9ff3c58c7ee5422ae40 Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 11 Mar 2026 21:33:27 +0100 Subject: [PATCH] Consolidate 36 per-change specs into 4 feature-level specs and align workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace granular change-level specs (001–036) with living feature specs: - 001-combatant-management (CRUD, persistence, clear, confirm buttons) - 002-turn-tracking (rounds, turn order, advance/retreat, top bar) - 003-combatant-state (HP, AC, conditions, concentration, initiative) - 004-bestiary (search, stat blocks, source management, panel UX) Workflow changes: - Add /integrate-issue command (replaces /issue-to-spec) for routing issues to existing specs or handing off to /speckit.specify - Update /sync-issue to list specs instead of requiring feature branch - Update /write-issue to reference /integrate-issue - Add RPI skills (research, plan, implement) to .claude/skills/ - Create docs/agents/ for RPI artifacts (research reports, plans) - Remove update-agent-context.sh call from /speckit.plan - Update CLAUDE.md with proportional scope-based workflow table - Bump constitution to 3.0.0 (specs describe features, not changes) Co-Authored-By: Claude Opus 4.6 --- .claude/commands/integrate-issue.md | 143 +++++ .claude/commands/issue-to-spec.md | 101 ---- .claude/commands/speckit.plan.md | 9 +- .claude/commands/sync-issue.md | 20 +- .claude/commands/write-issue.md | 2 +- .claude/skills/rpi-implement/SKILL.md | 82 +++ .claude/skills/rpi-plan/SKILL.md | 349 +++++++++++++ .claude/skills/rpi-plan/scripts/metadata.py | 37 ++ .claude/skills/rpi-research/SKILL.md | 146 ++++++ .../skills/rpi-research/scripts/metadata.py | 39 ++ .specify/memory/constitution.md | 25 +- CLAUDE.md | 56 +- docs/agents/.gitkeep | 4 + docs/agents/plans/.gitkeep | 0 docs/agents/research/.gitkeep | 0 specs/001-advance-turn/plan.md | 349 ------------- specs/001-advance-turn/spec.md | 172 ------ specs/001-advance-turn/tasks.md | 128 ----- specs/001-combatant-management/spec.md | 383 ++++++++++++++ .../checklists/requirements.md | 35 -- specs/002-add-combatant/data-model.md | 77 --- specs/002-add-combatant/plan.md | 76 --- specs/002-add-combatant/quickstart.md | 47 -- specs/002-add-combatant/research.md | 40 -- specs/002-add-combatant/spec.md | 161 ------ specs/002-add-combatant/tasks.md | 129 ----- specs/002-turn-tracking/spec.md | 319 ++++++++++++ specs/003-combatant-state/spec.md | 492 ++++++++++++++++++ .../checklists/requirements.md | 34 -- specs/003-remove-combatant/data-model.md | 69 --- specs/003-remove-combatant/plan.md | 71 --- specs/003-remove-combatant/quickstart.md | 39 -- specs/003-remove-combatant/research.md | 48 -- specs/003-remove-combatant/spec.md | 101 ---- specs/003-remove-combatant/tasks.md | 117 ----- specs/004-bestiary/spec.md | 301 +++++++++++ .../checklists/requirements.md | 34 -- .../contracts/domain-contract.md | 37 -- specs/004-edit-combatant/data-model.md | 59 --- specs/004-edit-combatant/plan.md | 70 --- specs/004-edit-combatant/quickstart.md | 41 -- specs/004-edit-combatant/research.md | 40 -- specs/004-edit-combatant/spec.md | 77 --- specs/004-edit-combatant/tasks.md | 147 ------ .../checklists/requirements.md | 34 -- .../contracts/domain-api.md | 57 -- specs/005-set-initiative/data-model.md | 63 --- specs/005-set-initiative/plan.md | 83 --- specs/005-set-initiative/quickstart.md | 36 -- specs/005-set-initiative/research.md | 49 -- specs/005-set-initiative/spec.md | 116 ----- specs/005-set-initiative/tasks.md | 179 ------- .../checklists/requirements.md | 34 -- specs/006-pre-commit-gate/plan.md | 67 --- specs/006-pre-commit-gate/quickstart.md | 34 -- specs/006-pre-commit-gate/research.md | 45 -- specs/006-pre-commit-gate/spec.md | 88 ---- specs/006-pre-commit-gate/tasks.md | 105 ---- specs/007-add-knip/checklists/requirements.md | 34 -- specs/007-add-knip/data-model.md | 24 - specs/007-add-knip/plan.md | 128 ----- specs/007-add-knip/quickstart.md | 29 -- specs/007-add-knip/research.md | 41 -- specs/007-add-knip/spec.md | 79 --- specs/007-add-knip/tasks.md | 122 ----- .../checklists/requirements.md | 34 -- specs/008-persist-encounter/data-model.md | 50 -- specs/008-persist-encounter/plan.md | 74 --- specs/008-persist-encounter/quickstart.md | 39 -- specs/008-persist-encounter/research.md | 62 --- specs/008-persist-encounter/spec.md | 90 ---- specs/008-persist-encounter/tasks.md | 145 ------ .../checklists/requirements.md | 34 -- specs/009-combatant-hp/data-model.md | 83 --- specs/009-combatant-hp/plan.md | 79 --- specs/009-combatant-hp/quickstart.md | 48 -- specs/009-combatant-hp/research.md | 73 --- specs/009-combatant-hp/spec.md | 122 ----- specs/009-combatant-hp/tasks.md | 156 ------ .../checklists/requirements.md | 36 -- .../contracts/ui-components.md | 78 --- specs/010-ui-baseline/data-model.md | 75 --- specs/010-ui-baseline/plan.md | 75 --- specs/010-ui-baseline/quickstart.md | 62 --- specs/010-ui-baseline/research.md | 72 --- specs/010-ui-baseline/spec.md | 140 ----- specs/010-ui-baseline/tasks.md | 207 -------- .../checklists/requirements.md | 36 -- specs/011-quick-hp-input/data-model.md | 87 ---- specs/011-quick-hp-input/plan.md | 80 --- specs/011-quick-hp-input/quickstart.md | 49 -- specs/011-quick-hp-input/research.md | 44 -- specs/011-quick-hp-input/spec.md | 133 ----- specs/011-quick-hp-input/tasks.md | 109 ---- .../checklists/requirements.md | 34 -- .../contracts/domain-api.md | 55 -- specs/012-turn-navigation/data-model.md | 58 --- specs/012-turn-navigation/plan.md | 76 --- specs/012-turn-navigation/quickstart.md | 49 -- specs/012-turn-navigation/research.md | 49 -- specs/012-turn-navigation/spec.md | 106 ---- specs/012-turn-navigation/tasks.md | 153 ------ .../checklists/requirements.md | 36 -- specs/013-hp-status-indicators/data-model.md | 46 -- specs/013-hp-status-indicators/plan.md | 64 --- specs/013-hp-status-indicators/quickstart.md | 46 -- specs/013-hp-status-indicators/research.md | 60 --- specs/013-hp-status-indicators/spec.md | 98 ---- specs/013-hp-status-indicators/tasks.md | 122 ----- .../checklists/requirements.md | 34 -- specs/014-inline-hp-delta/data-model.md | 42 -- specs/014-inline-hp-delta/plan.md | 70 --- specs/014-inline-hp-delta/quickstart.md | 43 -- specs/014-inline-hp-delta/research.md | 37 -- specs/014-inline-hp-delta/spec.md | 104 ---- specs/014-inline-hp-delta/tasks.md | 143 ----- .../checklists/requirements.md | 34 -- specs/015-add-jscpd-gate/data-model.md | 16 - specs/015-add-jscpd-gate/plan.md | 66 --- specs/015-add-jscpd-gate/quickstart.md | 36 -- specs/015-add-jscpd-gate/research.md | 49 -- specs/015-add-jscpd-gate/spec.md | 99 ---- specs/015-add-jscpd-gate/tasks.md | 126 ----- .../checklists/requirements.md | 34 -- specs/016-combatant-ac/data-model.md | 58 --- specs/016-combatant-ac/plan.md | 81 --- specs/016-combatant-ac/quickstart.md | 64 --- specs/016-combatant-ac/research.md | 57 -- specs/016-combatant-ac/spec.md | 93 ---- specs/016-combatant-ac/tasks.md | 152 ------ .../checklists/requirements.md | 35 -- specs/017-combat-conditions/data-model.md | 88 ---- specs/017-combat-conditions/plan.md | 80 --- specs/017-combat-conditions/quickstart.md | 57 -- specs/017-combat-conditions/research.md | 51 -- specs/017-combat-conditions/spec.md | 147 ------ specs/017-combat-conditions/tasks.md | 164 ------ .../checklists/requirements.md | 34 -- .../018-combatant-concentration/data-model.md | 45 -- specs/018-combatant-concentration/plan.md | 73 --- .../018-combatant-concentration/quickstart.md | 48 -- specs/018-combatant-concentration/research.md | 50 -- specs/018-combatant-concentration/spec.md | 106 ---- specs/018-combatant-concentration/tasks.md | 154 ------ .../checklists/requirements.md | 34 -- .../019-combatant-row-declutter/data-model.md | 29 -- specs/019-combatant-row-declutter/plan.md | 75 --- .../019-combatant-row-declutter/quickstart.md | 44 -- specs/019-combatant-row-declutter/research.md | 42 -- specs/019-combatant-row-declutter/spec.md | 113 ---- specs/019-combatant-row-declutter/tasks.md | 154 ------ .../checklists/requirements.md | 35 -- specs/020-fix-zero-hp-opacity/data-model.md | 14 - specs/020-fix-zero-hp-opacity/plan.md | 81 --- specs/020-fix-zero-hp-opacity/quickstart.md | 47 -- specs/020-fix-zero-hp-opacity/research.md | 84 --- specs/020-fix-zero-hp-opacity/spec.md | 82 --- specs/020-fix-zero-hp-opacity/tasks.md | 101 ---- .../checklists/requirements.md | 35 -- .../contracts/ui-contracts.md | 140 ----- specs/021-bestiary-statblock/data-model.md | 146 ------ specs/021-bestiary-statblock/plan.md | 96 ---- specs/021-bestiary-statblock/quickstart.md | 74 --- specs/021-bestiary-statblock/research.md | 91 ---- specs/021-bestiary-statblock/spec.md | 131 ----- specs/021-bestiary-statblock/tasks.md | 198 ------- .../checklists/requirements.md | 35 -- specs/022-fixed-layout-bars/plan.md | 112 ---- specs/022-fixed-layout-bars/quickstart.md | 29 -- specs/022-fixed-layout-bars/research.md | 33 -- specs/022-fixed-layout-bars/spec.md | 75 --- specs/022-fixed-layout-bars/tasks.md | 100 ---- .../checklists/requirements.md | 34 -- specs/023-clear-encounter/data-model.md | 43 -- specs/023-clear-encounter/plan.md | 75 --- specs/023-clear-encounter/quickstart.md | 42 -- specs/023-clear-encounter/research.md | 40 -- specs/023-clear-encounter/spec.md | 74 --- specs/023-clear-encounter/tasks.md | 146 ------ .../checklists/requirements.md | 34 -- specs/024-fix-hp-popover-overflow/plan.md | 96 ---- specs/024-fix-hp-popover-overflow/research.md | 26 - specs/024-fix-hp-popover-overflow/spec.md | 65 --- specs/024-fix-hp-popover-overflow/tasks.md | 91 ---- .../checklists/requirements.md | 35 -- specs/025-display-initiative/data-model.md | 63 --- specs/025-display-initiative/plan.md | 68 --- specs/025-display-initiative/quickstart.md | 35 -- specs/025-display-initiative/research.md | 59 --- specs/025-display-initiative/spec.md | 84 --- specs/025-display-initiative/tasks.md | 143 ----- .../checklists/requirements.md | 34 -- specs/026-roll-initiative/data-model.md | 68 --- specs/026-roll-initiative/plan.md | 79 --- specs/026-roll-initiative/quickstart.md | 54 -- specs/026-roll-initiative/research.md | 71 --- specs/026-roll-initiative/spec.md | 84 --- specs/026-roll-initiative/tasks.md | 130 ----- .../027-ui-polish/checklists/requirements.md | 36 -- specs/027-ui-polish/data-model.md | 25 - specs/027-ui-polish/plan.md | 129 ----- specs/027-ui-polish/quickstart.md | 39 -- specs/027-ui-polish/research.md | 66 --- specs/027-ui-polish/spec.md | 155 ------ specs/027-ui-polish/tasks.md | 187 ------- .../checklists/requirements.md | 35 -- specs/028-semantic-hover-tokens/data-model.md | 59 --- specs/028-semantic-hover-tokens/plan.md | 70 --- specs/028-semantic-hover-tokens/quickstart.md | 37 -- specs/028-semantic-hover-tokens/research.md | 83 --- specs/028-semantic-hover-tokens/spec.md | 96 ---- specs/028-semantic-hover-tokens/tasks.md | 132 ----- .../checklists/requirements.md | 36 -- .../contracts/bestiary-port.md | 66 --- specs/029-on-demand-bestiary/data-model.md | 133 ----- specs/029-on-demand-bestiary/plan.md | 94 ---- specs/029-on-demand-bestiary/quickstart.md | 76 --- specs/029-on-demand-bestiary/research.md | 113 ---- specs/029-on-demand-bestiary/spec.md | 131 ----- specs/029-on-demand-bestiary/tasks.md | 198 ------- .../checklists/requirements.md | 34 -- specs/030-bulk-import-sources/data-model.md | 42 -- specs/030-bulk-import-sources/plan.md | 67 --- specs/030-bulk-import-sources/quickstart.md | 44 -- specs/030-bulk-import-sources/research.md | 70 --- specs/030-bulk-import-sources/spec.md | 128 ----- specs/030-bulk-import-sources/tasks.md | 175 ------- .../checklists/requirements.md | 35 -- specs/031-quality-gates-hygiene/data-model.md | 35 -- specs/031-quality-gates-hygiene/plan.md | 81 --- specs/031-quality-gates-hygiene/quickstart.md | 40 -- specs/031-quality-gates-hygiene/research.md | 107 ---- specs/031-quality-gates-hygiene/spec.md | 144 ----- specs/031-quality-gates-hygiene/tasks.md | 211 -------- .../checklists/requirements.md | 35 -- .../032-inline-confirm-buttons/data-model.md | 37 -- specs/032-inline-confirm-buttons/plan.md | 65 --- .../032-inline-confirm-buttons/quickstart.md | 40 -- specs/032-inline-confirm-buttons/research.md | 62 --- specs/032-inline-confirm-buttons/spec.md | 100 ---- specs/032-inline-confirm-buttons/tasks.md | 151 ------ .../checklists/requirements.md | 35 -- .../data-model.md | 3 - specs/033-fix-concentration-glow-clip/plan.md | 69 --- .../quickstart.md | 20 - .../research.md | 35 -- specs/033-fix-concentration-glow-clip/spec.md | 68 --- .../033-fix-concentration-glow-clip/tasks.md | 115 ---- .../checklists/requirements.md | 34 -- specs/034-topbar-redesign/data-model.md | 29 -- specs/034-topbar-redesign/plan.md | 61 --- specs/034-topbar-redesign/quickstart.md | 39 -- specs/034-topbar-redesign/research.md | 53 -- specs/034-topbar-redesign/spec.md | 89 ---- specs/034-topbar-redesign/tasks.md | 156 ------ .../checklists/requirements.md | 36 -- specs/035-statblock-fold-pin/data-model.md | 62 --- specs/035-statblock-fold-pin/plan.md | 127 ----- specs/035-statblock-fold-pin/quickstart.md | 41 -- specs/035-statblock-fold-pin/research.md | 66 --- specs/035-statblock-fold-pin/spec.md | 102 ---- specs/035-statblock-fold-pin/tasks.md | 191 ------- .../checklists/requirements.md | 34 -- specs/036-bottombar-overhaul/data-model.md | 78 --- specs/036-bottombar-overhaul/plan.md | 68 --- specs/036-bottombar-overhaul/quickstart.md | 39 -- specs/036-bottombar-overhaul/research.md | 48 -- specs/036-bottombar-overhaul/spec.md | 118 ----- specs/036-bottombar-overhaul/tasks.md | 156 ------ 269 files changed, 2360 insertions(+), 19461 deletions(-) create mode 100644 .claude/commands/integrate-issue.md delete mode 100644 .claude/commands/issue-to-spec.md create mode 100644 .claude/skills/rpi-implement/SKILL.md create mode 100644 .claude/skills/rpi-plan/SKILL.md create mode 100755 .claude/skills/rpi-plan/scripts/metadata.py create mode 100644 .claude/skills/rpi-research/SKILL.md create mode 100755 .claude/skills/rpi-research/scripts/metadata.py create mode 100644 docs/agents/.gitkeep create mode 100644 docs/agents/plans/.gitkeep create mode 100644 docs/agents/research/.gitkeep delete mode 100644 specs/001-advance-turn/plan.md delete mode 100644 specs/001-advance-turn/spec.md delete mode 100644 specs/001-advance-turn/tasks.md create mode 100644 specs/001-combatant-management/spec.md delete mode 100644 specs/002-add-combatant/checklists/requirements.md delete mode 100644 specs/002-add-combatant/data-model.md delete mode 100644 specs/002-add-combatant/plan.md delete mode 100644 specs/002-add-combatant/quickstart.md delete mode 100644 specs/002-add-combatant/research.md delete mode 100644 specs/002-add-combatant/spec.md delete mode 100644 specs/002-add-combatant/tasks.md create mode 100644 specs/002-turn-tracking/spec.md create mode 100644 specs/003-combatant-state/spec.md delete mode 100644 specs/003-remove-combatant/checklists/requirements.md delete mode 100644 specs/003-remove-combatant/data-model.md delete mode 100644 specs/003-remove-combatant/plan.md delete mode 100644 specs/003-remove-combatant/quickstart.md delete mode 100644 specs/003-remove-combatant/research.md delete mode 100644 specs/003-remove-combatant/spec.md delete mode 100644 specs/003-remove-combatant/tasks.md create mode 100644 specs/004-bestiary/spec.md delete mode 100644 specs/004-edit-combatant/checklists/requirements.md delete mode 100644 specs/004-edit-combatant/contracts/domain-contract.md delete mode 100644 specs/004-edit-combatant/data-model.md delete mode 100644 specs/004-edit-combatant/plan.md delete mode 100644 specs/004-edit-combatant/quickstart.md delete mode 100644 specs/004-edit-combatant/research.md delete mode 100644 specs/004-edit-combatant/spec.md delete mode 100644 specs/004-edit-combatant/tasks.md delete mode 100644 specs/005-set-initiative/checklists/requirements.md delete mode 100644 specs/005-set-initiative/contracts/domain-api.md delete mode 100644 specs/005-set-initiative/data-model.md delete mode 100644 specs/005-set-initiative/plan.md delete mode 100644 specs/005-set-initiative/quickstart.md delete mode 100644 specs/005-set-initiative/research.md delete mode 100644 specs/005-set-initiative/spec.md delete mode 100644 specs/005-set-initiative/tasks.md delete mode 100644 specs/006-pre-commit-gate/checklists/requirements.md delete mode 100644 specs/006-pre-commit-gate/plan.md delete mode 100644 specs/006-pre-commit-gate/quickstart.md delete mode 100644 specs/006-pre-commit-gate/research.md delete mode 100644 specs/006-pre-commit-gate/spec.md delete mode 100644 specs/006-pre-commit-gate/tasks.md delete mode 100644 specs/007-add-knip/checklists/requirements.md delete mode 100644 specs/007-add-knip/data-model.md delete mode 100644 specs/007-add-knip/plan.md delete mode 100644 specs/007-add-knip/quickstart.md delete mode 100644 specs/007-add-knip/research.md delete mode 100644 specs/007-add-knip/spec.md delete mode 100644 specs/007-add-knip/tasks.md delete mode 100644 specs/008-persist-encounter/checklists/requirements.md delete mode 100644 specs/008-persist-encounter/data-model.md delete mode 100644 specs/008-persist-encounter/plan.md delete mode 100644 specs/008-persist-encounter/quickstart.md delete mode 100644 specs/008-persist-encounter/research.md delete mode 100644 specs/008-persist-encounter/spec.md delete mode 100644 specs/008-persist-encounter/tasks.md delete mode 100644 specs/009-combatant-hp/checklists/requirements.md delete mode 100644 specs/009-combatant-hp/data-model.md delete mode 100644 specs/009-combatant-hp/plan.md delete mode 100644 specs/009-combatant-hp/quickstart.md delete mode 100644 specs/009-combatant-hp/research.md delete mode 100644 specs/009-combatant-hp/spec.md delete mode 100644 specs/009-combatant-hp/tasks.md delete mode 100644 specs/010-ui-baseline/checklists/requirements.md delete mode 100644 specs/010-ui-baseline/contracts/ui-components.md delete mode 100644 specs/010-ui-baseline/data-model.md delete mode 100644 specs/010-ui-baseline/plan.md delete mode 100644 specs/010-ui-baseline/quickstart.md delete mode 100644 specs/010-ui-baseline/research.md delete mode 100644 specs/010-ui-baseline/spec.md delete mode 100644 specs/010-ui-baseline/tasks.md delete mode 100644 specs/011-quick-hp-input/checklists/requirements.md delete mode 100644 specs/011-quick-hp-input/data-model.md delete mode 100644 specs/011-quick-hp-input/plan.md delete mode 100644 specs/011-quick-hp-input/quickstart.md delete mode 100644 specs/011-quick-hp-input/research.md delete mode 100644 specs/011-quick-hp-input/spec.md delete mode 100644 specs/011-quick-hp-input/tasks.md delete mode 100644 specs/012-turn-navigation/checklists/requirements.md delete mode 100644 specs/012-turn-navigation/contracts/domain-api.md delete mode 100644 specs/012-turn-navigation/data-model.md delete mode 100644 specs/012-turn-navigation/plan.md delete mode 100644 specs/012-turn-navigation/quickstart.md delete mode 100644 specs/012-turn-navigation/research.md delete mode 100644 specs/012-turn-navigation/spec.md delete mode 100644 specs/012-turn-navigation/tasks.md delete mode 100644 specs/013-hp-status-indicators/checklists/requirements.md delete mode 100644 specs/013-hp-status-indicators/data-model.md delete mode 100644 specs/013-hp-status-indicators/plan.md delete mode 100644 specs/013-hp-status-indicators/quickstart.md delete mode 100644 specs/013-hp-status-indicators/research.md delete mode 100644 specs/013-hp-status-indicators/spec.md delete mode 100644 specs/013-hp-status-indicators/tasks.md delete mode 100644 specs/014-inline-hp-delta/checklists/requirements.md delete mode 100644 specs/014-inline-hp-delta/data-model.md delete mode 100644 specs/014-inline-hp-delta/plan.md delete mode 100644 specs/014-inline-hp-delta/quickstart.md delete mode 100644 specs/014-inline-hp-delta/research.md delete mode 100644 specs/014-inline-hp-delta/spec.md delete mode 100644 specs/014-inline-hp-delta/tasks.md delete mode 100644 specs/015-add-jscpd-gate/checklists/requirements.md delete mode 100644 specs/015-add-jscpd-gate/data-model.md delete mode 100644 specs/015-add-jscpd-gate/plan.md delete mode 100644 specs/015-add-jscpd-gate/quickstart.md delete mode 100644 specs/015-add-jscpd-gate/research.md delete mode 100644 specs/015-add-jscpd-gate/spec.md delete mode 100644 specs/015-add-jscpd-gate/tasks.md delete mode 100644 specs/016-combatant-ac/checklists/requirements.md delete mode 100644 specs/016-combatant-ac/data-model.md delete mode 100644 specs/016-combatant-ac/plan.md delete mode 100644 specs/016-combatant-ac/quickstart.md delete mode 100644 specs/016-combatant-ac/research.md delete mode 100644 specs/016-combatant-ac/spec.md delete mode 100644 specs/016-combatant-ac/tasks.md delete mode 100644 specs/017-combat-conditions/checklists/requirements.md delete mode 100644 specs/017-combat-conditions/data-model.md delete mode 100644 specs/017-combat-conditions/plan.md delete mode 100644 specs/017-combat-conditions/quickstart.md delete mode 100644 specs/017-combat-conditions/research.md delete mode 100644 specs/017-combat-conditions/spec.md delete mode 100644 specs/017-combat-conditions/tasks.md delete mode 100644 specs/018-combatant-concentration/checklists/requirements.md delete mode 100644 specs/018-combatant-concentration/data-model.md delete mode 100644 specs/018-combatant-concentration/plan.md delete mode 100644 specs/018-combatant-concentration/quickstart.md delete mode 100644 specs/018-combatant-concentration/research.md delete mode 100644 specs/018-combatant-concentration/spec.md delete mode 100644 specs/018-combatant-concentration/tasks.md delete mode 100644 specs/019-combatant-row-declutter/checklists/requirements.md delete mode 100644 specs/019-combatant-row-declutter/data-model.md delete mode 100644 specs/019-combatant-row-declutter/plan.md delete mode 100644 specs/019-combatant-row-declutter/quickstart.md delete mode 100644 specs/019-combatant-row-declutter/research.md delete mode 100644 specs/019-combatant-row-declutter/spec.md delete mode 100644 specs/019-combatant-row-declutter/tasks.md delete mode 100644 specs/020-fix-zero-hp-opacity/checklists/requirements.md delete mode 100644 specs/020-fix-zero-hp-opacity/data-model.md delete mode 100644 specs/020-fix-zero-hp-opacity/plan.md delete mode 100644 specs/020-fix-zero-hp-opacity/quickstart.md delete mode 100644 specs/020-fix-zero-hp-opacity/research.md delete mode 100644 specs/020-fix-zero-hp-opacity/spec.md delete mode 100644 specs/020-fix-zero-hp-opacity/tasks.md delete mode 100644 specs/021-bestiary-statblock/checklists/requirements.md delete mode 100644 specs/021-bestiary-statblock/contracts/ui-contracts.md delete mode 100644 specs/021-bestiary-statblock/data-model.md delete mode 100644 specs/021-bestiary-statblock/plan.md delete mode 100644 specs/021-bestiary-statblock/quickstart.md delete mode 100644 specs/021-bestiary-statblock/research.md delete mode 100644 specs/021-bestiary-statblock/spec.md delete mode 100644 specs/021-bestiary-statblock/tasks.md delete mode 100644 specs/022-fixed-layout-bars/checklists/requirements.md delete mode 100644 specs/022-fixed-layout-bars/plan.md delete mode 100644 specs/022-fixed-layout-bars/quickstart.md delete mode 100644 specs/022-fixed-layout-bars/research.md delete mode 100644 specs/022-fixed-layout-bars/spec.md delete mode 100644 specs/022-fixed-layout-bars/tasks.md delete mode 100644 specs/023-clear-encounter/checklists/requirements.md delete mode 100644 specs/023-clear-encounter/data-model.md delete mode 100644 specs/023-clear-encounter/plan.md delete mode 100644 specs/023-clear-encounter/quickstart.md delete mode 100644 specs/023-clear-encounter/research.md delete mode 100644 specs/023-clear-encounter/spec.md delete mode 100644 specs/023-clear-encounter/tasks.md delete mode 100644 specs/024-fix-hp-popover-overflow/checklists/requirements.md delete mode 100644 specs/024-fix-hp-popover-overflow/plan.md delete mode 100644 specs/024-fix-hp-popover-overflow/research.md delete mode 100644 specs/024-fix-hp-popover-overflow/spec.md delete mode 100644 specs/024-fix-hp-popover-overflow/tasks.md delete mode 100644 specs/025-display-initiative/checklists/requirements.md delete mode 100644 specs/025-display-initiative/data-model.md delete mode 100644 specs/025-display-initiative/plan.md delete mode 100644 specs/025-display-initiative/quickstart.md delete mode 100644 specs/025-display-initiative/research.md delete mode 100644 specs/025-display-initiative/spec.md delete mode 100644 specs/025-display-initiative/tasks.md delete mode 100644 specs/026-roll-initiative/checklists/requirements.md delete mode 100644 specs/026-roll-initiative/data-model.md delete mode 100644 specs/026-roll-initiative/plan.md delete mode 100644 specs/026-roll-initiative/quickstart.md delete mode 100644 specs/026-roll-initiative/research.md delete mode 100644 specs/026-roll-initiative/spec.md delete mode 100644 specs/026-roll-initiative/tasks.md delete mode 100644 specs/027-ui-polish/checklists/requirements.md delete mode 100644 specs/027-ui-polish/data-model.md delete mode 100644 specs/027-ui-polish/plan.md delete mode 100644 specs/027-ui-polish/quickstart.md delete mode 100644 specs/027-ui-polish/research.md delete mode 100644 specs/027-ui-polish/spec.md delete mode 100644 specs/027-ui-polish/tasks.md delete mode 100644 specs/028-semantic-hover-tokens/checklists/requirements.md delete mode 100644 specs/028-semantic-hover-tokens/data-model.md delete mode 100644 specs/028-semantic-hover-tokens/plan.md delete mode 100644 specs/028-semantic-hover-tokens/quickstart.md delete mode 100644 specs/028-semantic-hover-tokens/research.md delete mode 100644 specs/028-semantic-hover-tokens/spec.md delete mode 100644 specs/028-semantic-hover-tokens/tasks.md delete mode 100644 specs/029-on-demand-bestiary/checklists/requirements.md delete mode 100644 specs/029-on-demand-bestiary/contracts/bestiary-port.md delete mode 100644 specs/029-on-demand-bestiary/data-model.md delete mode 100644 specs/029-on-demand-bestiary/plan.md delete mode 100644 specs/029-on-demand-bestiary/quickstart.md delete mode 100644 specs/029-on-demand-bestiary/research.md delete mode 100644 specs/029-on-demand-bestiary/spec.md delete mode 100644 specs/029-on-demand-bestiary/tasks.md delete mode 100644 specs/030-bulk-import-sources/checklists/requirements.md delete mode 100644 specs/030-bulk-import-sources/data-model.md delete mode 100644 specs/030-bulk-import-sources/plan.md delete mode 100644 specs/030-bulk-import-sources/quickstart.md delete mode 100644 specs/030-bulk-import-sources/research.md delete mode 100644 specs/030-bulk-import-sources/spec.md delete mode 100644 specs/030-bulk-import-sources/tasks.md delete mode 100644 specs/031-quality-gates-hygiene/checklists/requirements.md delete mode 100644 specs/031-quality-gates-hygiene/data-model.md delete mode 100644 specs/031-quality-gates-hygiene/plan.md delete mode 100644 specs/031-quality-gates-hygiene/quickstart.md delete mode 100644 specs/031-quality-gates-hygiene/research.md delete mode 100644 specs/031-quality-gates-hygiene/spec.md delete mode 100644 specs/031-quality-gates-hygiene/tasks.md delete mode 100644 specs/032-inline-confirm-buttons/checklists/requirements.md delete mode 100644 specs/032-inline-confirm-buttons/data-model.md delete mode 100644 specs/032-inline-confirm-buttons/plan.md delete mode 100644 specs/032-inline-confirm-buttons/quickstart.md delete mode 100644 specs/032-inline-confirm-buttons/research.md delete mode 100644 specs/032-inline-confirm-buttons/spec.md delete mode 100644 specs/032-inline-confirm-buttons/tasks.md delete mode 100644 specs/033-fix-concentration-glow-clip/checklists/requirements.md delete mode 100644 specs/033-fix-concentration-glow-clip/data-model.md delete mode 100644 specs/033-fix-concentration-glow-clip/plan.md delete mode 100644 specs/033-fix-concentration-glow-clip/quickstart.md delete mode 100644 specs/033-fix-concentration-glow-clip/research.md delete mode 100644 specs/033-fix-concentration-glow-clip/spec.md delete mode 100644 specs/033-fix-concentration-glow-clip/tasks.md delete mode 100644 specs/034-topbar-redesign/checklists/requirements.md delete mode 100644 specs/034-topbar-redesign/data-model.md delete mode 100644 specs/034-topbar-redesign/plan.md delete mode 100644 specs/034-topbar-redesign/quickstart.md delete mode 100644 specs/034-topbar-redesign/research.md delete mode 100644 specs/034-topbar-redesign/spec.md delete mode 100644 specs/034-topbar-redesign/tasks.md delete mode 100644 specs/035-statblock-fold-pin/checklists/requirements.md delete mode 100644 specs/035-statblock-fold-pin/data-model.md delete mode 100644 specs/035-statblock-fold-pin/plan.md delete mode 100644 specs/035-statblock-fold-pin/quickstart.md delete mode 100644 specs/035-statblock-fold-pin/research.md delete mode 100644 specs/035-statblock-fold-pin/spec.md delete mode 100644 specs/035-statblock-fold-pin/tasks.md delete mode 100644 specs/036-bottombar-overhaul/checklists/requirements.md delete mode 100644 specs/036-bottombar-overhaul/data-model.md delete mode 100644 specs/036-bottombar-overhaul/plan.md delete mode 100644 specs/036-bottombar-overhaul/quickstart.md delete mode 100644 specs/036-bottombar-overhaul/research.md delete mode 100644 specs/036-bottombar-overhaul/spec.md delete mode 100644 specs/036-bottombar-overhaul/tasks.md diff --git a/.claude/commands/integrate-issue.md b/.claude/commands/integrate-issue.md new file mode 100644 index 0000000..eaf3a46 --- /dev/null +++ b/.claude/commands/integrate-issue.md @@ -0,0 +1,143 @@ +--- +description: Fetch a Gitea issue, identify the affected feature spec(s), and integrate the issue's requirements into the spec. For new features, hands off to /speckit.specify. +--- + +## User Input + +```text +$ARGUMENTS +``` + +You **MUST** provide an issue number as the argument (e.g. `/integrate-issue 6`). If `$ARGUMENTS` is empty or not a valid number, ask the user for the issue number. + +## Prerequisites + +1. Verify the `GITEA_TOKEN_ISSUES` environment variable is set: + +```bash +test -n "$GITEA_TOKEN_ISSUES" && echo "TOKEN_OK" || echo "TOKEN_MISSING" +``` + +If missing, tell the user to set it: +``` +export GITEA_TOKEN_ISSUES="your-gitea-personal-access-token" +``` +Then abort. + +2. Parse the git remote to extract the Gitea API base URL, owner, and repo: + +```bash +git config --get remote.origin.url +``` + +Expected format: `ssh://git@://.git` or `https:////.git` + +Extract: +- `GITEA_HOST` — the hostname +- `OWNER` — the repo owner/org +- `REPO` — the repo name (strip `.git` suffix) +- `API_BASE` — `https:///api/v1` + +## Execution + +### Step 1 — Fetch the issue + +```bash +curl -sf -H "Authorization: token $GITEA_TOKEN_ISSUES" "$API_BASE/repos/$OWNER/$REPO/issues/" +``` + +Extract from the JSON response: +- `title` — the issue title +- `body` — the issue body (markdown) +- `labels` — array of label names (if any) + +If the API call fails or returns no issue, abort with a clear error. + +### Step 2 — Fetch issue comments (if any) + +```bash +curl -sf -H "Authorization: token $GITEA_TOKEN_ISSUES" "$API_BASE/repos/$OWNER/$REPO/issues//comments" +``` + +If comments exist, include them as additional context (they may contain clarifications or requirements discussed after the issue was created). + +### Step 3 — Route: new feature or existing feature? + +List the existing feature specs by reading the `specs/` directory: + +```bash +ls -d specs/*/ +``` + +Present the issue summary and existing specs to the user. Ask: + +**"Does this issue belong to an existing feature, or is it a new feature?"** + +Present options: +- Each existing spec as a numbered option (show spec name and one-line description from CLAUDE.md or the spec's overview) +- A "New feature" option + +If the user selects **New feature**, compose the feature description from the issue content (title + body + comments) and hand off to `/speckit.specify`. Stop here. + +If the user selects an **existing spec**, continue to Step 4. + +### Step 4 — Read the affected spec + +Load the selected spec file. Identify the sections that the issue's requirements affect: +- Which user stories need updating? +- Which requirements (FR-NNN) need adding or modifying? +- Which acceptance scenarios change? +- Are new edge cases introduced? + +Present your analysis to the user: +- **Stories affected**: list the story IDs/titles that need changes +- **New stories needed**: if the issue introduces behavior not covered by any existing story +- **Requirements to add/modify**: list specific FR numbers or new ones needed + +Ask the user to confirm or adjust the scope. + +### Step 5 — Draft spec changes + +For each affected section, draft the specific changes: + +- **Modified stories**: show the before/after for acceptance scenarios +- **New stories**: write them in the spec's format (matching the existing story naming convention — e.g., `**Story HP-7**` for combatant-state, `**Story A4**` for combatant-management) +- **New/modified requirements**: write them with the next available FR number +- **New edge cases**: add to the relevant edge cases section + +For per-topic specs (003-combatant-state, 004-bestiary), place changes in the correct topic section. + +### Step 6 — Preview and confirm + +Show the user a complete preview of all changes: +- Which file(s) will be modified +- The exact additions/modifications (as diffs or before/after blocks) + +Ask for confirmation before writing. + +### Step 7 — Write changes + +On confirmation: +- Write the updated spec file(s) +- Report what was changed (sections touched, stories added/modified, requirements added) + +### Step 8 — Suggest next steps + +Report completion and suggest next steps based on scope: + +- **Straightforward change** (1-2 stories, clear acceptance scenarios): "Implement the changes and commit" +- **Larger change** (multiple stories, cross-cutting concerns): "Use `rpi-research` to investigate the affected code, then `rpi-plan` to create a phased implementation plan, then `rpi-implement` to execute it" +- **Complex or ambiguous change**: "Run `/speckit.clarify` to resolve remaining ambiguities before implementing" +- Always: "Run `/sync-issue ` to update the Gitea issue with the new acceptance criteria" + +## Behavior Rules + +- Never modify the issue on Gitea — this is a read-only operation on the issue side. +- Always preview before writing spec changes — never write without user confirmation. +- Include comment authors in the context so requirements can be attributed. +- If the issue body is empty, warn the user but still proceed with just the title. +- Strip HTML tags from the body/comments if present (Gitea sometimes includes rendered HTML). +- Use `curl` for all API calls — do not rely on `gh` CLI. +- Match the existing spec's naming conventions for stories, requirements, and structure. +- When adding to per-topic specs (003, 004), place content in the correct topic section — do not create new top-level sections unless the change introduces an entirely new topic area. +- Increment FR/SC numbers from the highest existing number in the spec. diff --git a/.claude/commands/issue-to-spec.md b/.claude/commands/issue-to-spec.md deleted file mode 100644 index 121b7a0..0000000 --- a/.claude/commands/issue-to-spec.md +++ /dev/null @@ -1,101 +0,0 @@ ---- -description: Fetch a Gitea issue and feed it into /speckit.specify as the feature description. -handoffs: - - label: Start speccing from this issue - agent: speckit.specify - prompt: "Create a spec from this issue" - send: true ---- - -## User Input - -```text -$ARGUMENTS -``` - -You **MUST** provide an issue number as the argument (e.g. `/issue-to-spec 42`). If `$ARGUMENTS` is empty or not a valid number, ask the user for the issue number. - -## Prerequisites - -1. Verify the `GITEA_TOKEN_ISSUES` environment variable is set: - -```bash -test -n "$GITEA_TOKEN_ISSUES" && echo "TOKEN_OK" || echo "TOKEN_MISSING" -``` - -If missing, tell the user to set it: -``` -export GITEA_TOKEN_ISSUES="your-gitea-personal-access-token" -``` -Then abort. - -2. Parse the git remote to extract the Gitea API base URL, owner, and repo: - -```bash -git config --get remote.origin.url -``` - -Expected format: `ssh://git@://.git` or `https:////.git` - -Extract: -- `GITEA_HOST` — the hostname -- `OWNER` — the repo owner/org -- `REPO` — the repo name (strip `.git` suffix) -- `API_BASE` — `https:///api/v1` - -## Execution - -### Step 1 — Fetch the issue - -```bash -curl -sf -H "Authorization: token $GITEA_TOKEN_ISSUES" "$API_BASE/repos/$OWNER/$REPO/issues/" -``` - -Extract from the JSON response: -- `title` — the issue title -- `body` — the issue body (markdown) -- `labels` — array of label names (if any) - -If the API call fails or returns no issue, abort with a clear error. - -### Step 2 — Fetch issue comments (if any) - -```bash -curl -sf -H "Authorization: token $GITEA_TOKEN_ISSUES" "$API_BASE/repos/$OWNER/$REPO/issues//comments" -``` - -If comments exist, append them to the context (they may contain clarifications or additional requirements discussed after the issue was created). - -### Step 3 — Compose the feature description - -Format the issue content into a feature description suitable for `/speckit.specify`: - -``` - - - - - ---- -Additional context from issue comments: -: -: -... -``` - -### Step 4 — Report and hand off - -Display a summary: -- Issue number and title -- Number of comments included -- The composed feature description - -Then hand off to `/speckit.specify` with the composed feature description as input. The handoff button in the UI will allow the user to proceed. - -## Behavior Rules - -- Never modify the issue on Gitea — this is a read-only operation. -- Include comment authors in the context so `/speckit.specify` can attribute requirements. -- If the issue body is empty, warn the user but still proceed with just the title. -- Strip HTML tags from the body/comments if present (Gitea sometimes includes rendered HTML). -- Use `curl` for all API calls — do not rely on `gh` CLI. diff --git a/.claude/commands/speckit.plan.md b/.claude/commands/speckit.plan.md index 393cbe1..0122e78 100644 --- a/.claude/commands/speckit.plan.md +++ b/.claude/commands/speckit.plan.md @@ -75,14 +75,7 @@ You **MUST** consider the user input before proceeding (if not empty). - Examples: public APIs for libraries, command schemas for CLI tools, endpoints for web services, grammars for parsers, UI contracts for applications - Skip if project is purely internal (build scripts, one-off tools, etc.) -3. **Agent context update**: - - Run `.specify/scripts/bash/update-agent-context.sh claude` - - These scripts detect which AI agent is in use - - Update the appropriate agent-specific context file - - Add only new technology from current plan - - Preserve manual additions between markers - -**Output**: data-model.md, /contracts/*, quickstart.md, agent-specific file +**Output**: data-model.md, /contracts/*, quickstart.md ## Key rules diff --git a/.claude/commands/sync-issue.md b/.claude/commands/sync-issue.md index 8c8c4d0..9b49cd6 100644 --- a/.claude/commands/sync-issue.md +++ b/.claude/commands/sync-issue.md @@ -38,23 +38,31 @@ Extract: - `REPO` — the repo name (strip `.git` suffix) - `API_BASE` — `https:///api/v1` -3. Locate the spec file. Run: +3. Locate the spec file. List the available feature specs: ```bash -.specify/scripts/bash/check-prerequisites.sh --json --paths-only +ls specs/*/spec.md ``` -Parse `FEATURE_SPEC` from the output. If it fails, ask the user to ensure they're on a feature branch with a spec. For single quotes in args, use escape syntax: e.g `'I'\''m Groot'` (or double-quote if possible: `"I'm Groot"`). +Present the specs to the user and ask which one contains the acceptance criteria for this issue. If only one spec exists, use it automatically. ## Execution ### Step 1 — Read the spec -Load the spec file at `FEATURE_SPEC`. Parse the **User Scenarios & Testing** section, specifically: +Load the spec file at `FEATURE_SPEC`. Extract user stories and acceptance scenarios using these patterns: -- Each `### User Story N - [Title]` block +**Flat specs** (001-combatant-management, 002-turn-tracking): +- Look for the `## User Scenarios & Testing` section +- Each `### ... Story ...` or `**Story ...** ` block - The **Acceptance Scenarios** numbered list within each story (Given/When/Then format) -- The **Edge Cases** section +- The **Edge Cases** section(s) + +**Per-topic specs** (003-combatant-state, 004-bestiary): +- Stories are nested inside topic sections (e.g., `## Hit Points` > `### User Stories` > `**Story HP-1 — ...**`) +- Scan ALL `##` sections for `**Story ...` or `**US-...` patterns +- Extract acceptance scenarios from each story regardless of nesting depth +- Collect edge cases from each topic section's `### Edge Cases` subsection ### Step 2 — Condense into business-level acceptance criteria diff --git a/.claude/commands/write-issue.md b/.claude/commands/write-issue.md index 28e2caf..dc58dc2 100644 --- a/.claude/commands/write-issue.md +++ b/.claude/commands/write-issue.md @@ -150,7 +150,7 @@ PAYLOAD Parse the response and report: - Issue number and URL (`https://///issues/`) -- Suggest next step: `/issue-to-spec ` to start speccing +- Suggest next step: `/integrate-issue ` to integrate into a feature spec ## Behavior Rules diff --git a/.claude/skills/rpi-implement/SKILL.md b/.claude/skills/rpi-implement/SKILL.md new file mode 100644 index 0000000..f7007ac --- /dev/null +++ b/.claude/skills/rpi-implement/SKILL.md @@ -0,0 +1,82 @@ +--- +name: rpi-implement +description: Execute approved implementation plans phase by phase with automated and manual verification. Use when the user explicitly says "implement the plan", "execute the plan", or "start implementing" and has a plan file ready. Do not use for ad-hoc coding tasks without a plan. +--- + +# Implement Plan + +You are tasked with implementing an approved technical plan. These plans contain phases with specific changes and success criteria. + +## Getting Started + +If the user provided a plan path, proceed directly. If no plan path was provided, check `docs/agents/plans/` for recent plans. If none found, ask the user for a path. + +When you have a plan: +- Read the plan completely and check for any existing checkmarks (`- [x]`) +- Read all files mentioned in the plan +- **Read files fully** - never use limit/offset parameters, you need complete context +- Think deeply about how the pieces fit together +- If you have a todo list, use it to track your progress +- Start implementing if you understand what needs to be done + +## Implementation Philosophy + +Plans are carefully designed, but reality can be messy. Your job is to: +- Follow the plan's intent while adapting to what you find +- Implement each phase fully before moving to the next +- Verify your work makes sense in the broader codebase context +- Keep plan checkboxes current: `[-]` before starting an item, `[x]` right after it passes verification. Never batch updates. + +When things don't match the plan exactly, think about why and communicate clearly. The plan is your guide, but your judgment matters too. + +If you encounter a mismatch: +- STOP and think deeply about why the plan can't be followed +- Present the issue clearly: + ``` + Issue in Phase [N]: + Expected: [what the plan says] + Found: [actual situation] + Why this matters: [explanation] + + How should I proceed? + ``` + +## Verification Approach + +After implementing a phase: +- Run the success criteria checks listed in the plan (test commands, linters, type checkers, etc.) +- Fix any issues before proceeding +- **Check if manual verification is needed**: Look at the plan's success criteria for the current phase. + - If the phase has **manual verification steps**, pause and inform the human: + ``` + Phase [N] Complete - Ready for Manual Verification + + Automated verification passed: + - [List automated checks that passed] + + Please perform the manual verification steps listed in the plan: + - [List manual verification items from the plan] + + Let me know when manual testing is complete so I can proceed to Phase [N+1]. + ``` + - If the phase has **only automated verification** (no manual steps), continue directly to the next phase without pausing. Just note in passing that the phase is complete and automated checks passed. + +Do not check off items in the manual testing steps until confirmed by the user. + +## If You Get Stuck + +When something isn't working as expected: +- First, make sure you've read and understood all the relevant code +- Consider if the codebase has evolved since the plan was written +- Present the mismatch clearly and ask for guidance + +Use sub-agents sparingly - mainly for targeted debugging or exploring unfamiliar territory. + +## Resuming Work + +If the plan has existing checkmarks: +- Trust that completed work is done +- Pick up from the first unchecked item +- Verify previous work only if something seems off + +Remember: You're implementing a solution, not just checking boxes. Keep the end goal in mind and maintain forward momentum. diff --git a/.claude/skills/rpi-plan/SKILL.md b/.claude/skills/rpi-plan/SKILL.md new file mode 100644 index 0000000..264b055 --- /dev/null +++ b/.claude/skills/rpi-plan/SKILL.md @@ -0,0 +1,349 @@ +--- +name: rpi-plan +description: Create detailed, phased implementation plans through interactive research and iteration. Use when the user explicitly asks to "create a plan", "plan the implementation", or "design an approach" for a feature, refactor, or bug fix. Do not use for quick questions or simple tasks. +--- + +# Implementation Plan + +You are tasked with creating detailed implementation plans through an interactive, iterative process. You should be skeptical, thorough, and work collaboratively with the user to produce high-quality technical specifications. + +## Initial Setup + +If the user already provided a task description, file path, or topic alongside this command, proceed directly to step 1 below. Only if no context was given, respond with: +``` +I'll help you create a detailed implementation plan. Let me start by understanding what we're building. + +Please provide: +1. A description of what you want to build or change +2. Any relevant context, constraints, or specific requirements +3. Pointers to related files or previous research + +I'll analyze this information and work with you to create a comprehensive plan. +``` +Then wait for the user's input. + +## Process Steps + +### Step 1: Context Gathering & Initial Analysis + +1. **Read all mentioned files immediately and FULLY**: + - Any files the user referenced (docs, research, code) + - **IMPORTANT**: Use the Read tool WITHOUT limit/offset parameters to read entire files + - **CRITICAL**: DO NOT spawn sub-tasks before reading these files yourself in the main context + - **NEVER** read files partially - if a file is mentioned, read it completely + +2. **Determine if research already exists**: + - If the user provided a research document (e.g. from `docs/agents/research/`), **trust it as the source of truth**. Do NOT re-research topics that the document already covers. Use its findings, file references, and architecture analysis directly as the basis for planning. + - **NEVER repeat or re-do research that has already been provided.** The plan phase is about turning existing research into actionable implementation steps, not about gathering information that's already available. + - If NO research document was provided, proceed with targeted research as described below. + +3. **Read the most relevant files directly into your main context**: + - Based on the research document and/or user input, identify the most relevant source files + - **Read these files yourself using the Read tool** — do NOT delegate this to sub-agents. You need these files in your own context to write an accurate plan. + - Focus on files that will be modified or that define interfaces/patterns you need to follow + +4. **Only spawn sub-agents for genuinely missing information**: + - Do NOT spawn sub-agents to re-discover what the research document already covers + - Only use sub-agents if there are specific gaps: e.g. the research doesn't cover test conventions, a specific API surface, or a file that was added after the research was written + - Each sub-agent should have a narrow, specific question to answer — not broad exploration + +5. **Analyze and verify understanding**: + - Cross-reference the requirements with actual code (and research document if provided) + - Identify any discrepancies or misunderstandings + - Note assumptions that need verification + - Determine true scope based on codebase reality + +6. **Present informed understanding and focused questions**: + ``` + Based on the task and my research of the codebase, I understand we need to [accurate summary]. + + I've found that: + - [Current implementation detail with file:line reference] + - [Relevant pattern or constraint discovered] + - [Potential complexity or edge case identified] + + Questions that my research couldn't answer: + - [Specific technical question that requires human judgment] + - [Business logic clarification] + - [Design preference that affects implementation] + ``` + + Only ask questions that you genuinely cannot answer through code investigation. + +### Step 2: Targeted Research & Discovery + +After getting initial clarifications: + +1. **If the user corrects any misunderstanding**: + - DO NOT just accept the correction + - Read the specific files/directories they mention directly into your context + - Only proceed once you've verified the facts yourself + +2. If you have a todo list, use it to track exploration progress + +3. **Fill in gaps — do NOT redo existing research**: + - If a research document was provided, identify only the specific gaps that need filling + - Read additional files directly when possible — only spawn sub-agents for searches where you don't know the file paths + - **Ask yourself before any research action: "Is this already covered by the provided research?"** If yes, skip it and use what's there. + +4. **Present findings and design options**: + ``` + Based on my research, here's what I found: + + **Current State:** + - [Key discovery about existing code] + - [Pattern or convention to follow] + + **Design Options:** + 1. [Option A] - [pros/cons] + 2. [Option B] - [pros/cons] + + **Open Questions:** + - [Technical uncertainty] + - [Design decision needed] + + Which approach aligns best with your vision? + ``` + +### Step 3: Plan Structure Development + +Once aligned on approach: + +1. **Create initial plan outline**: + ``` + Here's my proposed plan structure: + + ## Overview + [1-2 sentence summary] + + ## Implementation Phases: + 1. [Phase name] - [what it accomplishes] + 2. [Phase name] - [what it accomplishes] + 3. [Phase name] - [what it accomplishes] + + Does this phasing make sense? Should I adjust the order or granularity? + ``` + +2. **Get feedback on structure** before writing details + +### Step 4: Detailed Plan Writing + +After structure approval: + +1. **Gather metadata**: + - Run `python /scripts/metadata.py` to get date, commit, branch, and repository info + - Determine the output filename: `docs/agents/plans/YYYY-MM-DD-description.md` + - YYYY-MM-DD is today's date + - description is a brief kebab-case description + - Example: `2025-01-08-improve-error-handling.md` + - The output folder (`docs/agents/plans/`) can be overridden by instructions in the project's `AGENTS.md` or `CLAUDE.md` + +2. **Write the plan** to `docs/agents/plans/YYYY-MM-DD-description.md` + - Ensure the `docs/agents/plans/` directory exists (create if needed) + - **Every actionable item must have a checkbox** (`- [ ]`) so progress can be tracked during implementation. This includes each change in "Changes Required" and each verification step in "Success Criteria". + - Use the template structure below: + +````markdown +--- +date: [ISO date/time from metadata] +git_commit: [Current commit hash from metadata] +branch: [Current branch name from metadata] +topic: "[Feature/Task Name]" +tags: [plan, relevant-component-names] +status: draft +--- + +# [Feature/Task Name] Implementation Plan + +## Overview + +[Brief description of what we're implementing and why] + +## Current State Analysis + +[What exists now, what's missing, key constraints discovered] + +## Desired End State + +[A specification of the desired end state after this plan is complete, and how to verify it] + +### UI Mockups (if applicable) +[If the changes involve user-facing interfaces (CLI output, web UI, terminal UI, etc.), include ASCII mockups +that visually illustrate the intended result. This helps the reader quickly grasp the change.] + +### Key Discoveries: +- [Important finding with file:line reference] +- [Pattern to follow] +- [Constraint to work within] + +## What We're NOT Doing + +[Explicitly list out-of-scope items to prevent scope creep] + +## Implementation Approach + +[High-level strategy and reasoning] + +## Phase 1: [Descriptive Name] + +### Overview +[What this phase accomplishes] + +### Changes Required: + +#### [ ] 1. [Component/File Group] +**File**: `path/to/file.ext` +**Changes**: [Summary of changes] + +```[language] +// Specific code to add/modify +``` + +### Success Criteria: + +#### Automated Verification: +- [ ] Tests pass: `[test command]` +- [ ] Type checking passes: `[typecheck command]` +- [ ] Linting passes: `[lint command]` + +#### Manual Verification: +- [ ] Feature works as expected when tested +- [ ] Edge case handling verified +- [ ] No regressions in related features + +**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase. + +--- + +## Phase 2: [Descriptive Name] + +[Similar structure with both automated and manual success criteria...] + +--- + +## Testing Strategy + +### Unit Tests: +- [What to test] +- [Key edge cases] + +### Integration Tests: +- [End-to-end scenarios] + +### Manual Testing Steps: +1. [Specific step to verify feature] +2. [Another verification step] + +## Performance Considerations + +[Any performance implications or optimizations needed] + +## Migration Notes + +[If applicable, how to handle existing data/systems] + +## References + +- [Related research or documentation] +- [Similar implementation: file:line] +```` + +### Step 5: Review & Iterate + +1. **Present the draft plan location**: + ``` + I've created the initial implementation plan at: + `docs/agents/plans/YYYY-MM-DD-description.md` + + Please review it and let me know: + - Are the phases properly scoped? + - Are the success criteria specific enough? + - Any technical details that need adjustment? + - Missing edge cases or considerations? + ``` + +2. **Iterate based on feedback** - be ready to: + - Add missing phases + - Adjust technical approach + - Clarify success criteria (both automated and manual) + - Add/remove scope items + +3. **Continue refining** until the user is satisfied + +## Important Guidelines + +1. **Be Skeptical**: + - Question vague requirements + - Identify potential issues early + - Ask "why" and "what about" + - Don't assume - verify with code + +2. **Be Interactive**: + - Don't write the full plan in one shot + - Get buy-in at each major step + - Allow course corrections + - Work collaboratively + +3. **Be Thorough But Not Redundant**: + - Read all context files COMPLETELY before planning + - Use provided research as-is — do not re-investigate what's already documented + - Read key source files directly into your context rather than delegating to sub-agents + - Only spawn sub-agents for narrow, specific questions that aren't answered by existing research + - Include specific file paths and line numbers + - Write measurable success criteria with clear automated vs manual distinction + +4. **Be Visual**: + - If the change involves any user-facing interface (web UI, CLI output, terminal UI, forms, dashboards, etc.), include ASCII mockups in the plan + - Mockups make the intended result immediately understandable and help catch misunderstandings early + - Show both the current state and the proposed state when the change modifies an existing UI + - Keep mockups simple but accurate enough to convey layout, key elements, and interactions + +5. **Be Practical**: + - Focus on incremental, testable changes + - Consider migration and rollback + - Think about edge cases + - Include "what we're NOT doing" + +6. **No Open Questions in Final Plan**: + - If you encounter open questions during planning, STOP + - Research or ask for clarification immediately + - Do NOT write the plan with unresolved questions + - The implementation plan must be complete and actionable + - Every decision must be made before finalizing the plan + +## Success Criteria Guidelines + +**Always separate success criteria into two categories:** + +1. **Automated Verification** (can be run by agents): + - Commands that can be run: test suites, linters, type checkers + - Specific files that should exist + - Code compilation/type checking + +2. **Manual Verification** (requires human testing): + - UI/UX functionality + - Performance under real conditions + - Edge cases that are hard to automate + - User acceptance criteria + +## Common Patterns + +### For Database Changes: +- Start with schema/migration +- Add store methods +- Update business logic +- Expose via API +- Update clients + +### For New Features: +- Research existing patterns first +- Start with data model +- Build backend logic +- Add API endpoints +- Implement UI last + +### For Refactoring: +- Document current behavior +- Plan incremental changes +- Maintain backwards compatibility +- Include migration strategy diff --git a/.claude/skills/rpi-plan/scripts/metadata.py b/.claude/skills/rpi-plan/scripts/metadata.py new file mode 100755 index 0000000..6914c27 --- /dev/null +++ b/.claude/skills/rpi-plan/scripts/metadata.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Get git metadata for plan documents.""" + +import json +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + + +def run(cmd: list[str]) -> str: + result = subprocess.run(cmd, capture_output=True, text=True) + return result.stdout.strip() if result.returncode == 0 else "" + + +def get_repo_name() -> str: + remote = run(["git", "remote", "get-url", "origin"]) + if remote: + name = remote.rstrip("/").rsplit("/", 1)[-1] + return name.removesuffix(".git") + root = run(["git", "rev-parse", "--show-toplevel"]) + return Path(root).name if root else Path.cwd().name + + +def main() -> None: + metadata = { + "date": datetime.now(timezone.utc).isoformat(), + "commit": run(["git", "rev-parse", "HEAD"]), + "branch": run(["git", "branch", "--show-current"]), + "repository": get_repo_name(), + } + json.dump(metadata, sys.stdout, indent=2) + print() + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/rpi-research/SKILL.md b/.claude/skills/rpi-research/SKILL.md new file mode 100644 index 0000000..71bda0c --- /dev/null +++ b/.claude/skills/rpi-research/SKILL.md @@ -0,0 +1,146 @@ +--- +name: rpi-research +description: Conduct deep codebase research and produce a written report. Use when the user explicitly requests research like "start a research for", "deeply investigate", or "fully understand how X works". Do not use for quick questions or simple code lookups. +--- + +# Research Codebase + +You are tasked with conducting comprehensive research across the codebase to answer user questions by spawning parallel sub-agents and synthesizing their findings. + +## CRITICAL: YOUR ONLY JOB IS TO DOCUMENT AND EXPLAIN THE CODEBASE AS IT EXISTS TODAY +- DO NOT suggest improvements or changes unless the user explicitly asks for them +- DO NOT perform root cause analysis unless the user explicitly asks for them +- DO NOT propose future enhancements unless the user explicitly asks for them +- DO NOT critique the implementation or identify problems +- DO NOT recommend refactoring, optimization, or architectural changes +- ONLY describe what exists, where it exists, how it works, and how components interact +- You are creating a technical map/documentation of the existing system + +## Initial Setup + +If the user already provided a research question or topic alongside this command, proceed directly to step 1 below. Only if no query was given, respond with: +``` +I'm ready to research the codebase. Please provide your research question or area of interest, and I'll analyze it thoroughly by exploring relevant components and connections. +``` +Then wait for the user's research query. + +## Steps to follow after receiving the research query: + +1. **Read any directly mentioned files first:** + - If the user mentions specific files or docs, read them FULLY first + - **IMPORTANT**: Use the Read tool WITHOUT limit/offset parameters to read entire files + - **CRITICAL**: Read these files yourself in the main context before spawning any sub-tasks + - This ensures you have full context before decomposing the research + +2. **Analyze and decompose the research question:** + - Break down the user's query into composable research areas + - Take time to think deeply about the underlying patterns, connections, and architectural implications the user might be seeking + - Identify specific components, patterns, or concepts to investigate + - If you have a todo list, use it to track progress + - Consider which directories, files, or architectural patterns are relevant + +3. **Spawn parallel sub-agents to identify relevant files and map the landscape:** + - Create multiple Task agents to search for files and identify what's relevant + - Each sub-agent should focus on locating files and reporting back paths and brief summaries — NOT on deeply analyzing code + - The key is to use these agents for discovery: + - Search for files related to each research area + - Identify entry points, key types, and important functions + - Report back file paths, line numbers, and short descriptions of what each file contains + - Run multiple agents in parallel when they're searching for different things + - Remind agents they are documenting, not evaluating or improving + - **If the user explicitly asks for web research**, spawn additional agents with WebSearch/WebFetch tools and instruct them to return links with their findings + +4. **Read the most relevant files yourself in the main context:** + - After sub-agents report back, identify the most important files for answering the research question + - **Read these files yourself using the Read tool** — you need them in your own context to write an accurate, detailed research document + - Do NOT rely solely on sub-agent summaries for the core findings — sub-agent summaries may miss nuances, connections, or important details + - Prioritize files that are central to the research question; skip peripheral files that sub-agents already summarized adequately + - This is the step where you build deep understanding — the previous step was just finding what to read + +5. **Synthesize findings into a complete picture:** + - Combine your own reading with sub-agent discoveries + - Connect findings across different components + - Include specific file paths and line numbers for reference + - Highlight patterns, connections, and architectural decisions + - Answer the user's specific questions with concrete evidence + +6. **Gather metadata for the research document:** + - Run `python /scripts/metadata.py` to get date, commit, branch, and repository info + - Determine the output filename: `docs/agents/research/YYYY-MM-DD-description.md` + - YYYY-MM-DD is today's date + - description is a brief kebab-case description of the research topic + - Example: `2025-01-08-authentication-flow.md` + - The output folder (`docs/agents/research/`) can be overridden by instructions in the project's `AGENTS.md` or `CLAUDE.md` + +7. **Generate research document:** + - Use the metadata gathered in step 5 + - Ensure the `docs/agents/research/` directory exists (create if needed) + - Structure the document with YAML frontmatter followed by content: + ```markdown + --- + date: [ISO date/time from metadata] + git_commit: [Current commit hash from metadata] + branch: [Current branch name from metadata] + topic: "[User's Question/Topic]" + tags: [research, codebase, relevant-component-names] + status: complete + --- + + # Research: [User's Question/Topic] + + ## Research Question + [Original user query] + + ## Summary + [High-level documentation of what was found, answering the user's question by describing what exists] + + ## Detailed Findings + + ### [Component/Area 1] + - Description of what exists (file.ext:line) + - How it connects to other components + - Current implementation details (without evaluation) + + ### [Component/Area 2] + ... + + ## Code References + - `path/to/file.py:123` - Description of what's there + - `another/file.ts:45-67` - Description of the code block + + ## Architecture Documentation + [Current patterns, conventions, and design implementations found in the codebase] + + ## Open Questions + [Any areas that need further investigation] + ``` + +8. **Present findings to the user:** + - Present a concise summary of findings + - Include key file references for easy navigation + - Ask if they have follow-up questions or need clarification + +9. **Handle follow-up questions:** + - If the user has follow-up questions, append to the same research document + - Add a new section: `## Follow-up Research [timestamp]` + - Spawn new sub-agents as needed for additional investigation + - Continue updating the document + +## Important notes: +- Use parallel sub-agents for file discovery and landscape mapping, but **read the most important files yourself** in the main context +- Sub-agents are scouts that find relevant files — the main agent must read key files to build deep understanding +- Do NOT rely solely on sub-agent summaries for your core findings; they may miss nuances and connections +- Focus on finding concrete file paths and line numbers for developer reference +- Research documents should be self-contained with all necessary context +- Each sub-agent prompt should be specific and focused on locating files and reporting back paths +- Document cross-component connections and how systems interact +- **CRITICAL**: You and all sub-agents are documentarians, not evaluators +- **REMEMBER**: Document what IS, not what SHOULD BE +- **NO RECOMMENDATIONS**: Only describe the current state of the codebase +- **File reading**: Always read mentioned files FULLY (no limit/offset) before spawning sub-tasks +- **Critical ordering**: Follow the numbered steps exactly + - ALWAYS read mentioned files first before spawning sub-tasks (step 1) + - ALWAYS read key files yourself after sub-agents report back (step 4) + - ALWAYS wait for your own reading to complete before synthesizing (step 5) + - ALWAYS gather metadata before writing the document (step 6 before step 7) + - NEVER write the research document with placeholder values diff --git a/.claude/skills/rpi-research/scripts/metadata.py b/.claude/skills/rpi-research/scripts/metadata.py new file mode 100755 index 0000000..fac4688 --- /dev/null +++ b/.claude/skills/rpi-research/scripts/metadata.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Get git metadata for research documents.""" + +import json +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + + +def run(cmd: list[str]) -> str: + result = subprocess.run(cmd, capture_output=True, text=True) + return result.stdout.strip() if result.returncode == 0 else "" + + +def get_repo_name() -> str: + remote = run(["git", "remote", "get-url", "origin"]) + if remote: + # Handle both HTTPS and SSH URLs + name = remote.rstrip("/").rsplit("/", 1)[-1] + return name.removesuffix(".git") + # Fall back to directory name + root = run(["git", "rev-parse", "--show-toplevel"]) + return Path(root).name if root else Path.cwd().name + + +def main() -> None: + metadata = { + "date": datetime.now(timezone.utc).isoformat(), + "commit": run(["git", "rev-parse", "HEAD"]), + "branch": run(["git", "branch", "--show-current"]), + "repository": get_repo_name(), + } + json.dump(metadata, sys.stdout, indent=2) + print() + + +if __name__ == "__main__": + main() diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index 3a70664..1b52dca 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,17 +1,10 @@ # Encounter Console Constitution @@ -99,8 +92,14 @@ architecture, and quality — not product behavior. - No change may be merged unless all automated checks (tests and static analysis as defined by the project) pass. -- Every feature begins with a spec (`/speckit.specify`). -- Implementation follows the plan → tasks → implement pipeline. +- Specs describe **features**, not individual changes. Each spec is + a living document. New features begin with `/speckit.specify` + (which creates a feature branch for the full speckit pipeline); + changes to existing features update the existing spec via + `/integrate-issue`. +- The full pipeline (spec → plan → tasks → implement) applies to new + features and significant additions. Bug fixes, tooling changes, + and trivial UI adjustments do not require specs. - Domain logic MUST be testable without mocks for external systems. - Long-running or multi-step state transitions SHOULD be verifiable through reproducible event logs or snapshot-style tests. @@ -141,4 +140,4 @@ MUST comply with its principles. **Compliance review**: Every spec and plan MUST include a Constitution Check section validating adherence to all principles. -**Version**: 2.2.1 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-11 +**Version**: 3.0.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-11 diff --git a/CLAUDE.md b/CLAUDE.md index 8c441ae..feff726 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,8 +46,10 @@ packages/domain/src/ Pure state transitions, types, validation packages/application/src/ Use cases, port interfaces data/bestiary/ Bestiary search index scripts/ Build tooling (layer checks, index generation) -specs// Feature specs (spec.md, plan.md, tasks.md) -.specify/memory/ Project constitution +specs/NNN-feature-name/ Feature specs (spec.md, plan.md, tasks.md) +.specify/ Speckit config (templates, scripts, constitution) +docs/agents/ RPI skill artifacts (research reports, plans) +.claude/skills/ Agent skills (rpi-research, rpi-plan, rpi-implement) ``` ## Tech Stack @@ -66,9 +68,40 @@ specs// Feature specs (spec.md, plan.md, tasks.md) - **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical. - **Domain events** are plain data objects with a `type` discriminant — no classes. - **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks. -- **Feature specs** live in `specs//` with spec.md, plan.md, tasks.md. The project constitution is at `.specify/memory/constitution.md`. +- **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`. - **Quality gates** are enforced at pre-commit via Lefthook's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process. +## Speckit Workflow + +Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Specs are **living documents** that describe features, not individual changes. + +### Issue-driven workflow +- `/write-issue` — create a well-structured Gitea issue via interactive interview +- `/integrate-issue ` — fetch an issue, route it to the right spec, and update the spec with the new/changed requirements. Then implement directly. +- `/sync-issue ` — push acceptance criteria from the spec back to the Gitea issue + +### RPI skills (Research → Plan → Implement) +- `rpi-research` — deep codebase research producing a written report in `docs/agents/research/` +- `rpi-plan` — interactive phased implementation plan in `docs/agents/plans/` +- `rpi-implement` — execute a plan file phase by phase with automated + manual verification + +### Choosing the right workflow by scope + +| Scope | Workflow | +|---|---| +| Bug fix / CSS tweak | Just fix it, commit | +| Small change to existing feature | `/integrate-issue` → implement → commit | +| Larger addition to existing feature | `/integrate-issue` → `rpi-research` → `rpi-plan` → `rpi-implement` | +| New feature | `/speckit.specify` → `/speckit.clarify` → `/speckit.plan` → `/speckit.tasks` → `/speckit.implement` | + +Speckit manages **what** to build (specs as living documents). RPI manages **how** to build it (research, planning, execution). The full speckit pipeline is for new features. For changes to existing features, update the spec via `/integrate-issue`, then use RPI skills if the change is non-trivial. + +### Current feature specs +- `specs/001-combatant-management/` — CRUD, persistence, clear, batch add, confirm buttons +- `specs/002-turn-tracking/` — rounds, turn order, advance/retreat, top bar +- `specs/003-combatant-state/` — HP, AC, conditions, concentration, initiative +- `specs/004-bestiary/` — search index, stat blocks, source management, panel UX + ## Constitution (key principles) The constitution (`.specify/memory/constitution.md`) governs all feature work: @@ -77,19 +110,4 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work: 2. **Layered Architecture** — Domain → Application → Adapters. Never skip layers or reverse dependencies. 3. **Clarification-First** — Ask before making non-trivial assumptions. 4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans. -5. **Every feature begins with a spec** — Spec → Plan → Tasks → Implementation. - - -## Active Technologies -- TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Tailwind CSS v4, Lucide React, class-variance-authority (cva) (032-inline-confirm-buttons) -- N/A (no persistence changes — confirm state is ephemeral) (032-inline-confirm-buttons) -- TypeScript 5.8, CSS (Tailwind CSS v4) + React 19, Tailwind CSS v4 (033-fix-concentration-glow-clip) -- N/A (no persistence changes) (033-fix-concentration-glow-clip) -- N/A (no persistence changes — display-only refactor) (034-topbar-redesign) -- TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Tailwind CSS v4, Lucide React (icons), class-variance-authority (035-statblock-fold-pin) -- N/A (no persistence changes — all new state is ephemeral) (035-statblock-fold-pin) -- TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Tailwind CSS v4, Lucide React (icons) (036-bottombar-overhaul) -- N/A (no persistence changes — queue state and custom fields are ephemeral) (036-bottombar-overhaul) - -## Recent Changes -- 032-inline-confirm-buttons: Added TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Tailwind CSS v4, Lucide React, class-variance-authority (cva) +5. **Spec-driven features** — Features are described in living specs; evolve existing specs via `/integrate-issue`, create new ones via `/speckit.specify`. Bug fixes and tooling changes do not require specs. diff --git a/docs/agents/.gitkeep b/docs/agents/.gitkeep new file mode 100644 index 0000000..5d95013 --- /dev/null +++ b/docs/agents/.gitkeep @@ -0,0 +1,4 @@ +# Agent Artifacts + +Research reports and implementation plans generated by RPI skills. + diff --git a/docs/agents/plans/.gitkeep b/docs/agents/plans/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/agents/research/.gitkeep b/docs/agents/research/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/specs/001-advance-turn/plan.md b/specs/001-advance-turn/plan.md deleted file mode 100644 index 49f6c12..0000000 --- a/specs/001-advance-turn/plan.md +++ /dev/null @@ -1,349 +0,0 @@ -# Implementation Plan: Advance Turn - -**Branch**: `001-advance-turn` | **Date**: 2026-03-03 | **Spec**: [spec.md](./spec.md) -**Input**: Feature specification from `/specs/001-advance-turn/spec.md` - -## Summary - -Implement the AdvanceTurn domain operation as a pure function that -transitions an Encounter to the next combatant, wrapping rounds and -emitting TurnAdvanced / RoundAdvanced domain events. Stand up the -pnpm monorepo skeleton, Biome tooling, and Vitest test harness so -that all constitution merge-gate requirements are satisfied from the -first commit. - -## Technical Context - -**Node**: 22 LTS (pinned via `.nvmrc`) -**Language/Version**: TypeScript 5.8 (strict mode) -**Primary Dependencies**: React 19 (pin to major; minor upgrades -allowed), Vite 6.2 -**Storage**: In-memory only (MVP baseline) -**Testing**: Vitest 3.0 -**Lint/Format**: Biome 2.0.0 (exact version, single tool — no -Prettier, no ESLint) -**Package Manager**: pnpm 10.6 (pinned via `packageManager` field -in root `package.json`) -**Target Platform**: Static web app (modern browsers) -**Project Type**: Monorepo — library packages + web app -**Performance Goals**: N/A (walking skeleton) -**Constraints**: Domain package must have zero React/Vite imports -**Scale/Scope**: Single feature, ~5 source files, ~1 test file - -## Constitution Check - -| Principle | Status | Notes | -|-----------|--------|-------| -| I. Deterministic Domain Core | PASS | `advanceTurn` is a pure function; no I/O, randomness, or clocks | -| II. Layered Architecture | PASS | `packages/domain` → `packages/application` → `apps/web`; strict dependency direction enforced by automated import check | -| III. Agent Boundary | N/A | Agent layer out of scope for this feature | -| IV. Clarification-First | PASS | No ambiguous decisions remain; spec fully clarified | -| V. Escalation Gates | PASS | Scope strictly limited to spec; out-of-scope items listed | -| VI. MVP Baseline Language | PASS | No permanent bans; "MVP baseline does not include" used | -| VII. No Gameplay Rules | PASS | No gameplay mechanics in plan or constitution | -| Merge Gate | PASS | `pnpm check` script runs format, lint, typecheck, test | - -## Project Structure - -### Documentation (this feature) - -```text -specs/001-advance-turn/ -├── spec.md -└── plan.md -``` - -### Source Code (repository root) - -```text -packages/ -├── domain/ -│ ├── package.json -│ ├── tsconfig.json -│ └── src/ -│ ├── types.ts # CombatantId, Combatant, Encounter -│ ├── events.ts # TurnAdvanced, RoundAdvanced, DomainEvent -│ ├── advance-turn.ts # advanceTurn pure function -│ └── index.ts # public barrel export -├── application/ -│ ├── package.json -│ ├── tsconfig.json -│ └── src/ -│ ├── ports.ts # EncounterStore port interface -│ ├── advance-turn-use-case.ts -│ └── index.ts -apps/ -└── web/ - ├── package.json - ├── tsconfig.json - ├── vite.config.ts - ├── index.html - └── src/ - ├── main.tsx - ├── App.tsx - └── hooks/ - └── use-encounter.ts - -# Testing (co-located with domain package) -packages/domain/ -└── src/ - └── __tests__/ - └── advance-turn.test.ts - -# Root config -├── .nvmrc # pins Node 22 -├── pnpm-workspace.yaml -├── biome.json -├── tsconfig.base.json -└── package.json # packageManager field pins pnpm; root scripts -``` - -**Structure Decision**: pnpm workspace monorepo with two packages -(`domain`, `application`) and one app (`web`). Domain is -framework-agnostic TypeScript. Application imports domain only. -Web app (React + Vite) imports both. - -## Tooling & Merge Gate - -### Scripts (root package.json) - -```jsonc -{ - "scripts": { - "format": "biome format --write .", - "format:check": "biome format .", - "lint": "biome lint .", - "lint:fix": "biome lint --write .", - "typecheck": "tsc --build", - "test": "vitest run", - "test:watch": "vitest", - "check": "biome check . && tsc --build && vitest run" - } -} -``` - -`pnpm check` is the single merge gate: format + lint + typecheck + -test. The layer boundary check runs as a Vitest test (see below), -so it executes as part of `vitest run` — no separate script needed. - -### Layer Boundary Enforcement - -Biome does not natively support cross-package import restrictions. -A lightweight `scripts/check-layer-boundaries.mjs` script will: - -1. Scan `packages/domain/src/**/*.ts` — assert zero imports from - `@initiative/application`, `apps/`, `react`, `vite`. -2. Scan `packages/application/src/**/*.ts` — assert zero imports - from `apps/`, `react`, `vite`. -3. Exit non-zero on violation with a clear error message. - -This script is invoked by a Vitest test -(`packages/domain/src/__tests__/layer-boundaries.test.ts`) so it -runs automatically as part of `vitest run` inside `pnpm check`. -No separate `check:layer` script is needed — the layer boundary -check is guaranteed to execute on every merge-gate run. - -### Biome Configuration (biome.json) - -```jsonc -{ - "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json", - "organizeImports": { "enabled": true }, - "formatter": { - "enabled": true, - "indentStyle": "tab", - "lineWidth": 80 - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true - } - } -} -``` - -### TypeScript Configuration - -- `tsconfig.base.json` at root: strict mode, composite projects, - path aliases (`@initiative/domain`, `@initiative/application`). -- Each package extends `tsconfig.base.json` with its own - `include`/`references`. -- `apps/web/tsconfig.json` references both packages. - -## Milestones - -### Milestone 1: Tooling & Domain (walking skeleton) - -Stand up monorepo, Biome, Vitest, TypeScript project references, -and implement the complete AdvanceTurn domain logic with all tests. - -**Exit criteria**: `pnpm check` passes. All 8 acceptance scenarios -green. Layer boundary check green. No React or Vite dependencies in -`packages/domain` or `packages/application`. - -### Milestone 2: Application + Minimal Web Shell - -Wire up the application use case and a minimal React UI that -displays the encounter state and has a single "Next Turn" button. - -**Exit criteria**: `pnpm check` passes. Clicking "Next Turn" in the -browser advances the turn with correct round wrapping. The app -builds with `vite build`. - -## Task List - -### Phase 1: Setup (Milestone 1) - -- [X] **T001** Initialize pnpm workspace and root config - - Create `pnpm-workspace.yaml` listing `packages/*` and `apps/*` - - Create `.nvmrc` pinning Node 22 - - Create root `package.json` with `packageManager` field pinning - pnpm 10.6 and scripts (check, test, lint, format, typecheck) - - Create `biome.json` at root - - Create `tsconfig.base.json` (strict, composite, path aliases) - - **Acceptance**: `pnpm install` succeeds; `biome check .` runs - without config errors - -- [X] **T002** [P] Create `packages/domain` package skeleton - - `package.json` (name: `@initiative/domain`, no dependencies) - - `tsconfig.json` extending base, composite: true - - Empty `src/index.ts` - - **Acceptance**: `tsc --build packages/domain` succeeds - -- [X] **T003** [P] Create `packages/application` package skeleton - - `package.json` (name: `@initiative/application`, - depends on `@initiative/domain`) - - `tsconfig.json` extending base, references domain - - Empty `src/index.ts` - - **Acceptance**: `tsc --build packages/application` succeeds - -- [X] **T004** [P] Create `apps/web` package skeleton - - `package.json` with React, Vite, depends on both packages - - `tsconfig.json` referencing both packages - - `vite.config.ts` (minimal) - - `index.html` + `src/main.tsx` + `src/App.tsx` (placeholder) - - **Acceptance**: `pnpm --filter web dev` starts; `vite build` - succeeds - -- [X] **T005** Configure Vitest - - Add `vitest` as root dev dependency - - Create `vitest.config.ts` at root (workspace mode) or per - package as needed - - Verify `pnpm test` runs (0 tests, exits clean) - - **Acceptance**: `pnpm test` exits 0 - -- [X] **T006** Create layer boundary check script - - `scripts/check-layer-boundaries.mjs`: scans domain and - application source for forbidden imports - - `packages/domain/src/__tests__/layer-boundaries.test.ts`: - wraps the script as a Vitest test - - **Acceptance**: test passes on clean skeleton; fails if a - forbidden import is manually added (verify, then remove) - -### Phase 2: Domain Implementation (Milestone 1) - -- [ ] **T007** Define domain types in `packages/domain/src/types.ts` - - `CombatantId` (branded string or opaque type) - - `Combatant` (carries a CombatantId) - - `Encounter` (combatants array, activeIndex, roundNumber) - - Factory function `createEncounter` that validates INV-1, INV-2, - INV-3 - - **Acceptance**: types compile; `createEncounter([])` returns - error; `createEncounter([a])` returns valid Encounter - -- [ ] **T008** [P] Define domain events in - `packages/domain/src/events.ts` - - `TurnAdvanced { previousCombatantId, newCombatantId, - roundNumber }` - - `RoundAdvanced { newRoundNumber }` - - `DomainEvent = TurnAdvanced | RoundAdvanced` - - **Acceptance**: types compile; events are plain data (no - classes with methods) - -- [ ] **T009** Implement `advanceTurn` in - `packages/domain/src/advance-turn.ts` - - Signature: `(encounter: Encounter) => - { encounter: Encounter; events: DomainEvent[] } | DomainError` - - Implements FR-001 through FR-005 - - Returns error for empty combatant list (INV-1) - - Emits TurnAdvanced on every call (INV-5) - - Emits TurnAdvanced then RoundAdvanced on wrap (event order - contract) - - **Acceptance**: compiles; satisfies type contract - -- [ ] **T010** Write tests for all 8 acceptance scenarios + - invariants in - `packages/domain/src/__tests__/advance-turn.test.ts` - - Scenarios 1–8 from spec (Given/When/Then) - - INV-1: empty encounter rejected - - INV-2: activeIndex always in bounds (property check across - scenarios) - - INV-3: roundNumber never decreases - - INV-4: determinism — same input produces same output (call - twice, assert deep equal) - - INV-5: every success emits at least TurnAdvanced - - Event ordering: on wrap, events array is - [TurnAdvanced, RoundAdvanced] in that order - - **Acceptance**: `pnpm test` — all tests green; `pnpm check` — - full pipeline green - -- [ ] **T011** Export public API from `packages/domain/src/index.ts` - - Re-export types, events, `advanceTurn`, `createEncounter` - - **Acceptance**: consuming packages can - `import { advanceTurn } from "@initiative/domain"` - -**Milestone 1 checkpoint**: `pnpm check` passes (format + lint + -typecheck + test + layer boundaries). All 8 scenarios + invariants -green. - -### Phase 3: Application + Web Shell (Milestone 2) - -- [ ] **T012** Define port interface in - `packages/application/src/ports.ts` - - `EncounterStore` port: `get(): Encounter`, `save(e: Encounter)` - - **Acceptance**: compiles; no imports from adapters or React - -- [ ] **T013** Implement `AdvanceTurnUseCase` in - `packages/application/src/advance-turn-use-case.ts` - - Accepts `EncounterStore` port - - Calls `advanceTurn` from domain, saves result, returns events - - **Acceptance**: compiles; imports only from `@initiative/domain` - and local ports - -- [ ] **T014** Export public API from - `packages/application/src/index.ts` - - Re-export use case and port types - - **Acceptance**: consuming app can import from - `@initiative/application` - -- [ ] **T015** Implement `useEncounter` hook in - `apps/web/src/hooks/use-encounter.ts` - - In-memory implementation of `EncounterStore` port (React state) - - Exposes current encounter state + `advanceTurn` action - - Initializes with a hardcoded 3-combatant encounter for demo - - **Acceptance**: hook compiles; usable in a React component - -- [ ] **T016** Wire up `App.tsx` - - Display: current combatant name, round number, combatant list - with active indicator - - Single "Next Turn" button calling the use case - - Display emitted events (optional, for demo clarity) - - **Acceptance**: `vite build` succeeds; clicking "Next Turn" - cycles through combatants and increments rounds correctly - -**Milestone 2 checkpoint**: `pnpm check` passes. App runs in -browser. Full round-trip from button click → domain pure function → -UI update verified manually. - -## Risks & Open Questions - -| # | Item | Severity | Mitigation | -|---|------|----------|------------| -| 1 | pnpm workspace + TypeScript project references can have path resolution quirks with Vite | Low | Use `vite-tsconfig-paths` plugin if needed; test early in T004 | -| 2 | Biome config format may change across versions | Low | Pinned to exact 2.0.0; `$schema` in config validates structure | -| 3 | Layer boundary script is a lightweight grep — not a full architectural fitness function | Low | Sufficient for walking skeleton; can upgrade to a Biome plugin or `dependency-cruiser` later if needed | - -## Complexity Tracking - -No constitution violations. No complexity justifications needed. diff --git a/specs/001-advance-turn/spec.md b/specs/001-advance-turn/spec.md deleted file mode 100644 index ef315e3..0000000 --- a/specs/001-advance-turn/spec.md +++ /dev/null @@ -1,172 +0,0 @@ -# Feature Specification: Advance Turn - -**Feature Branch**: `001-advance-turn` -**Created**: 2026-03-03 -**Status**: Draft -**Input**: Walking-skeleton domain feature — deterministic turn advancement - -## User Scenarios & Testing *(mandatory)* - -### User Story 1 - Advance Turn (Priority: P1) - -A game master running an encounter advances the turn to the next -combatant in initiative order. When the last combatant in the round -finishes, the round number increments and play wraps to the first -combatant. - -**Why this priority**: This is the irreducible core of an initiative -tracker. Without turn advancement, no other feature has meaning. - -**Independent Test**: Can be fully tested as a pure state transition -with no I/O, persistence, or UI. Given an Encounter value and an -AdvanceTurn action, assert the resulting Encounter value and emitted -domain events. - -**Acceptance Scenarios**: - -1. **Given** an encounter with combatants [A, B, C], activeIndex 0, - roundNumber 1, - **When** AdvanceTurn, - **Then** activeIndex is 1, roundNumber is 1, - and a TurnAdvanced event is emitted with - previousCombatantId A, newCombatantId B, roundNumber 1. - -2. **Given** an encounter with combatants [A, B, C], activeIndex 1, - roundNumber 1, - **When** AdvanceTurn, - **Then** activeIndex is 2, roundNumber is 1, - and a TurnAdvanced event is emitted with - previousCombatantId B, newCombatantId C, roundNumber 1. - -3. **Given** an encounter with combatants [A, B, C], activeIndex 2, - roundNumber 1, - **When** AdvanceTurn, - **Then** activeIndex is 0, roundNumber is 2, - and events are emitted in order: TurnAdvanced - (previousCombatantId C, newCombatantId A, roundNumber 2) then - RoundAdvanced (newRoundNumber 2). - -4. **Given** an encounter with combatants [A, B, C], activeIndex 2, - roundNumber 5, - **When** AdvanceTurn, - **Then** activeIndex is 0, roundNumber is 6, - and events are emitted in order: TurnAdvanced then RoundAdvanced - (verifies round increment is not hardcoded to 2). - -5. **Given** an encounter with a single combatant [A], activeIndex 0, - roundNumber 1, - **When** AdvanceTurn, - **Then** activeIndex is 0, roundNumber is 2, - and events are emitted in order: TurnAdvanced - (previousCombatantId A, newCombatantId A, roundNumber 2) then - RoundAdvanced (newRoundNumber 2). - -6. **Given** an encounter with combatants [A, B], activeIndex 0, - roundNumber 1, - **When** AdvanceTurn is applied twice in sequence, - **Then** after the first: activeIndex 1, roundNumber 1; - after the second: activeIndex 0, roundNumber 2. - -7. **Given** an encounter with an empty combatant list, - **When** AdvanceTurn, - **Then** the operation MUST fail with an invalid-encounter error. - No events are emitted. State is unchanged. - -8. **Given** an encounter with combatants [A, B, C], activeIndex 0, - roundNumber 1, - **When** AdvanceTurn is applied three times, - **Then** the encounter completes a full round cycle: - activeIndex returns to 0 and roundNumber is 2. - ---- - -### Edge Cases - -- Empty combatant list: valid aggregate state, but AdvanceTurn MUST - return a DomainError (no state change, no events). -- Single combatant: every advance wraps and increments the round. -- Large round numbers: no overflow or special-case behavior; round - increments uniformly. - -## Clarifications - -### Session 2026-03-03 - -- Q: Should an encounter with zero combatants be a valid aggregate state? → A: Yes. Empty encounter is valid; AdvanceTurn returns DomainError. -- Q: What is activeIndex when combatants list is empty? → A: activeIndex MUST be 0. -- Q: Does this change any non-empty encounter behavior? → A: No. All existing acceptance scenarios and event contracts remain unchanged. - -## Domain Model *(mandatory)* - -### Key Entities - -- **Combatant**: An identified participant in the encounter. For this - feature, a combatant is an opaque identity (e.g., a name or id). - The MVP baseline does not include HP, conditions, or stats. -- **Encounter**: The aggregate root. Contains an ordered list of - combatants (pre-sorted by initiative), an activeIndex pointing to - the current combatant, and a roundNumber (positive integer, - starting at 1). - -### Domain Events - -- **TurnAdvanced**: Emitted on every successful AdvanceTurn. - Carries: previousCombatantId, newCombatantId, roundNumber. -- **RoundAdvanced**: Emitted when activeIndex wraps past the last - combatant. Carries: newRoundNumber. - -When a round boundary is crossed, both TurnAdvanced and -RoundAdvanced MUST be emitted in that order (TurnAdvanced first). -This emission order is part of the observable domain contract and -MUST be verified by tests. - -### Invariants - -- **INV-1**: An encounter MAY have zero combatants (an empty - encounter is a valid aggregate state). AdvanceTurn on an empty - encounter MUST return a DomainError with no state change and no - events. -- **INV-2**: If combatants.length > 0, activeIndex MUST satisfy - 0 <= activeIndex < combatants.length. If combatants.length == 0, - activeIndex MUST be 0. -- **INV-3**: roundNumber MUST be a positive integer (>= 1) and MUST - only increase (never decrease or reset). -- **INV-4**: AdvanceTurn MUST be a pure function of the current - encounter state. Given identical input, output MUST be identical. -- **INV-5**: Every successful AdvanceTurn MUST emit at least one - domain event (TurnAdvanced). No silent state changes. - -## Requirements *(mandatory)* - -### Functional Requirements - -- **FR-001**: The domain MUST expose an AdvanceTurn operation that - accepts an Encounter and returns the next Encounter state plus - emitted domain events. -- **FR-002**: AdvanceTurn MUST increment activeIndex by 1, wrapping - to 0 when past the last combatant. -- **FR-003**: When activeIndex wraps to 0, roundNumber MUST - increment by 1. -- **FR-004**: AdvanceTurn on an empty encounter MUST return an error - without modifying state or emitting events. -- **FR-005**: Domain events MUST be returned as values from the - operation, not dispatched via side effects. - -### Out of Scope (MVP baseline does not include) - -- Initiative rolling or combatant ordering logic -- Hit points, damage, conditions, or status effects -- Adding or removing combatants mid-encounter -- Persistence, serialization, or storage -- UI, CLI, or any adapter layer -- Agent behavior or suggestions - -## Success Criteria *(mandatory)* - -### Measurable Outcomes - -- **SC-001**: All 8 acceptance scenarios pass as deterministic, - pure-function tests with no I/O dependencies. -- **SC-002**: Invariants INV-1 through INV-5 are verified by tests. -- **SC-003**: The domain module has zero imports from application, - adapter, or agent layers (layer boundary compliance). diff --git a/specs/001-advance-turn/tasks.md b/specs/001-advance-turn/tasks.md deleted file mode 100644 index 7650083..0000000 --- a/specs/001-advance-turn/tasks.md +++ /dev/null @@ -1,128 +0,0 @@ -# Tasks: Advance Turn - -**Input**: Design documents from `/specs/001-advance-turn/` -**Prerequisites**: plan.md (required), spec.md (required) - -**Organization**: Tasks follow the phased structure from plan.md. There is only one user story (US1 — Advance Turn, P1), so phases map directly to the plan's milestones. - -## Format: `[ID] [P?] [Story?] Description` - -- **[P]**: Can run in parallel (different files, no dependencies) -- **[US1]**: User Story 1 — Advance Turn -- Exact file paths included in every task - ---- - -## Phase 1: Setup (Milestone 1 — Tooling) - -**Purpose**: Initialize pnpm monorepo, Biome, TypeScript, Vitest, and layer boundary enforcement - -- [X] T001 Initialize pnpm workspace and root config — create `pnpm-workspace.yaml`, `.nvmrc` (Node 22), root `package.json` (with `packageManager` pinning pnpm 10.6 and scripts: check, test, lint, format, typecheck), `biome.json`, and `tsconfig.base.json` (strict, composite, path aliases) -- [X] T002 [P] Create `packages/domain` package skeleton — `packages/domain/package.json` (`@initiative/domain`, no deps), `packages/domain/tsconfig.json` (extends base, composite), empty `packages/domain/src/index.ts` -- [X] T003 [P] Create `packages/application` package skeleton — `packages/application/package.json` (`@initiative/application`, depends on `@initiative/domain`), `packages/application/tsconfig.json` (extends base, references domain), empty `packages/application/src/index.ts` -- [X] T004 [P] Create `apps/web` package skeleton — `apps/web/package.json` (React 19, Vite 6.2, depends on both packages), `apps/web/tsconfig.json`, `apps/web/vite.config.ts`, `apps/web/index.html`, `apps/web/src/main.tsx`, `apps/web/src/App.tsx` (placeholder) -- [X] T005 Configure Vitest — add `vitest` as root dev dependency, create `vitest.config.ts` at root (workspace mode or per-package), verify `pnpm test` exits 0 -- [X] T006 Create layer boundary check — `scripts/check-layer-boundaries.mjs` (scans domain/application for forbidden imports) and `packages/domain/src/__tests__/layer-boundaries.test.ts` (wraps script as Vitest test) - -**Checkpoint**: `pnpm install` succeeds, `biome check .` runs, `tsc --build` compiles, `pnpm test` exits 0 with layer boundary test green. - ---- - -## Phase 2: Domain Implementation — User Story 1: Advance Turn (Priority: P1) (Milestone 1) - -**Goal**: Implement the complete AdvanceTurn domain logic as a pure function with all 8 acceptance scenarios and invariant tests. - -**Independent Test**: Pure state transition — given an Encounter value and AdvanceTurn action, assert resulting Encounter and emitted domain events. No I/O, persistence, or UI needed. - -- [X] T007 [US1] Define domain types in `packages/domain/src/types.ts` — `CombatantId` (branded/opaque), `Combatant`, `Encounter` (combatants, activeIndex, roundNumber), factory `createEncounter` enforcing INV-1, INV-2, INV-3 -- [X] T008 [P] [US1] Define domain events in `packages/domain/src/events.ts` — `TurnAdvanced`, `RoundAdvanced`, `DomainEvent` union (plain data, no classes) -- [X] T009 [US1] Implement `advanceTurn` in `packages/domain/src/advance-turn.ts` — pure function `(Encounter) => { encounter, events } | DomainError`, implements FR-001 through FR-005 -- [X] T010 [US1] Write tests for all 8 acceptance scenarios + invariants in `packages/domain/src/__tests__/advance-turn.test.ts` — scenarios 1–8, INV-1 through INV-5, event ordering on round wrap -- [X] T011 [US1] Export public API from `packages/domain/src/index.ts` — re-export types, events, `advanceTurn`, `createEncounter` - -**Checkpoint (Milestone 1)**: `pnpm check` passes (format + lint + typecheck + test + layer boundaries). All 8 scenarios + invariants green. No React/Vite imports in domain or application. - ---- - -## Phase 3: Application + Web Shell (Milestone 2) - -**Goal**: Wire up the application use case and minimal React UI with a "Next Turn" button. - -- [X] T012 Define port interface in `packages/application/src/ports.ts` — `EncounterStore` port: `get(): Encounter`, `save(e: Encounter)` -- [X] T013 Implement `AdvanceTurnUseCase` in `packages/application/src/advance-turn-use-case.ts` — accepts `EncounterStore`, calls `advanceTurn`, saves result, returns events -- [X] T014 Export public API from `packages/application/src/index.ts` — re-export use case and port types -- [X] T015 Implement `useEncounter` hook in `apps/web/src/hooks/use-encounter.ts` — in-memory `EncounterStore` via React state, exposes encounter state + `advanceTurn` action, hardcoded 3-combatant demo -- [X] T016 Wire up `apps/web/src/App.tsx` — display current combatant, round number, combatant list with active indicator, "Next Turn" button, emitted events - -**Checkpoint (Milestone 2)**: `pnpm check` passes. `vite build` succeeds. Clicking "Next Turn" cycles combatants and increments rounds correctly. - ---- - -## Dependencies & Execution Order - -### Phase Dependencies - -- **Phase 1 (Setup)**: No dependencies — start immediately -- **Phase 2 (Domain)**: Depends on Phase 1 completion -- **Phase 3 (App + Web)**: Depends on Phase 2 completion (needs domain types and `advanceTurn`) - -### Within Phase 1 - -- T001 must complete first (workspace and root config) -- T002, T003, T004 can run in parallel [P] after T001 -- T005 depends on T001 (needs root package.json) -- T006 depends on T002 and T005 (needs domain package + Vitest) - -### Within Phase 2 - -- T007 must complete first (types needed by everything) -- T008 can run in parallel [P] with T007 (events are independent types) -- T009 depends on T007 and T008 (uses types and events) -- T010 depends on T009 (tests the implementation) -- T011 depends on T007, T008, T009 (exports all public API) - -### Within Phase 3 - -- T012 first (port interface) -- T013 depends on T012 (uses port) -- T014 depends on T013 (exports use case) -- T015 depends on T014 (uses application layer) -- T016 depends on T015 (uses hook) - ---- - -## Parallel Opportunities - -```text -# After T001 completes: -T002, T003, T004 — all package skeletons in parallel - -# After T007 starts: -T008 — domain events can be written in parallel with types - -# Independent stories: only one user story (US1), so parallelism is within-phase only -``` - ---- - -## Implementation Strategy - -### MVP First (Milestone 1) - -1. Complete Phase 1: Setup (T001–T006) -2. Complete Phase 2: Domain (T007–T011) -3. **STOP and VALIDATE**: `pnpm check` passes, all 8 scenarios green - -### Full Feature (Milestone 2) - -4. Complete Phase 3: App + Web Shell (T012–T016) -5. **VALIDATE**: `pnpm check` passes, app runs in browser - ---- - -## Notes - -- All task IDs (T001–T016) match plan.md — no scope expansion -- Single user story (US1: Advance Turn) — no cross-story dependencies -- Tests (T010) are included as specified in plan.md and spec.md -- Domain package must have zero React/Vite imports (enforced by T006) diff --git a/specs/001-combatant-management/spec.md b/specs/001-combatant-management/spec.md new file mode 100644 index 0000000..e6e5397 --- /dev/null +++ b/specs/001-combatant-management/spec.md @@ -0,0 +1,383 @@ +# Feature Specification: Combatant Management + +**Feature Branch**: `001-combatant-management` +**Created**: 2026-03-03 +**Status**: Implemented + +## Overview + +Combatant Management covers the complete lifecycle of combatants within an encounter: adding (individually or in batch), editing, removing, clearing the entire encounter, persisting encounter state across page reloads, and the confirmation UX applied to all destructive actions. + +--- + +## User Scenarios & Testing *(mandatory)* + +### Adding Combatants + +**Story A1 — Add a single combatant (Priority: P1)** + +A game master adds a new combatant to an existing encounter. The new combatant is appended to the end of the initiative order, allowing late-joining participants or newly discovered enemies to enter combat. + +**Acceptance Scenarios**: + +1. **Given** an empty encounter (no combatants, activeIndex 0, roundNumber 1), **When** AddCombatant with name "Gandalf", **Then** combatants is [Gandalf], activeIndex is 0, roundNumber is 1, and a CombatantAdded event is emitted with the new combatant's id, name "Gandalf", and position 0. + +2. **Given** an encounter with combatants [A, B], activeIndex 0, roundNumber 1, **When** AddCombatant with name "C", **Then** combatants is [A, B, C], activeIndex is 0, roundNumber is 1, and a CombatantAdded event is emitted with position 2. + +3. **Given** an encounter with combatants [A, B, C], activeIndex 2, roundNumber 3, **When** AddCombatant with name "D", **Then** combatants is [A, B, C, D], activeIndex is 2, roundNumber is 3, and a CombatantAdded event is emitted with position 3. The active combatant does not change. + +4. **Given** an encounter with combatants [A], **When** AddCombatant is applied twice with names "B" then "C", **Then** combatants is [A, B, C] in that order. Each operation emits its own CombatantAdded event. + +5. **Given** an encounter with combatants [A, B], **When** AddCombatant with an empty name "", **Then** the operation MUST fail with a validation error. No events are emitted. State is unchanged. + +6. **Given** an encounter with combatants [A, B], **When** AddCombatant with a whitespace-only name " ", **Then** the operation MUST fail with a validation error. No events are emitted. State is unchanged. + +--- + +> **Batch add and custom creature workflows** are defined in `specs/004-bestiary/spec.md` (Stories US-S2, US-S3). Those stories cover the bestiary search dropdown, count badge, batch confirm, and custom creature stat fields. This spec covers only the domain-level AddCombatant operation that those workflows invoke. + +--- + +### Removing Combatants + +**Story B1 — Remove a combatant from an active encounter (Priority: P1)** + +A game master is running a combat encounter and a combatant is defeated or leaves. The GM removes that combatant. The combatant disappears from the initiative order and the turn continues correctly without disruption. + +**Acceptance Scenarios**: + +1. **Given** an encounter with combatants [A, B, C] and activeIndex 1 (B's turn), **When** the GM removes combatant C (index 2, after active), **Then** the encounter has [A, B], activeIndex remains 1, roundNumber unchanged, and a CombatantRemoved event is emitted. + +2. **Given** an encounter with combatants [A, B, C] and activeIndex 2 (C's turn), **When** the GM removes combatant A (index 0, before active), **Then** the encounter has [B, C], activeIndex becomes 1 (still C's turn), roundNumber unchanged. + +3. **Given** an encounter with combatants [A, B, C] and activeIndex 1 (B's turn), **When** the GM removes combatant B (the active combatant), **Then** the encounter has [A, C], activeIndex becomes 1 (C is now active), roundNumber unchanged. + +4. **Given** an encounter with combatants [A, B, C] and activeIndex 2 (C's turn, last position), **When** the GM removes combatant C (active and last), **Then** the encounter has [A, B], activeIndex wraps to 0 (A is now active), roundNumber unchanged. + +5. **Given** an encounter with combatants [A] and activeIndex 0, **When** the GM removes combatant A, **Then** the encounter has [], activeIndex is 0, roundNumber unchanged. + +6. **Given** an encounter with combatants [A, B, C], **When** the GM attempts to remove a combatant with an ID that does not exist, **Then** a domain error is returned with code `"combatant-not-found"`, and the encounter is unchanged. + +--- + +**Story B2 — Inline confirmation before removing (Priority: P1)** + +A user clicking the remove (X) button on a combatant row is protected from accidental deletion by a two-step inline confirmation flow. + +**Acceptance Scenarios**: + +1. **Given** a combatant row is visible, **When** the user clicks the remove (X) button once, **Then** the button transitions to a confirm state showing a checkmark icon on a red/danger background with a scale pulse animation. + +2. **Given** the remove button is in confirm state, **When** the user clicks it again, **Then** the combatant is removed from the encounter. + +3. **Given** the remove button is in confirm state, **When** 5 seconds elapse without a second click, **Then** the button reverts to its original X icon and default styling. + +4. **Given** the remove button is in confirm state, **When** the user clicks outside the button, **Then** the button reverts to its original state without removing the combatant. + +5. **Given** the remove button is in confirm state, **When** the user presses Escape, **Then** the button reverts to its original state without removing the combatant. + +6. **Given** a destructive button has keyboard focus, **When** the user presses Enter or Space, **Then** the button enters confirm state. + +7. **Given** a destructive button is in confirm state with focus, **When** the user presses Enter or Space, **Then** the destructive action executes. + +8. **Given** a destructive button is in confirm state with focus, **When** the user presses Escape, **Then** the button reverts to its original state. + +9. **Given** a destructive button is in confirm state, **When** the button loses focus (e.g., Tab away), **Then** the button reverts to its original state. + +--- + +### Editing Combatants + +**Story C1 — Rename a combatant (Priority: P1)** + +A user running an encounter realizes a combatant's name is misspelled or wants to change it. They select the combatant by its identity, provide a new name, and the system updates the combatant in-place while preserving turn order and round state. + +**Acceptance Scenarios**: + +1. **Given** an encounter with combatants [Alice, Bob], **When** the user updates Bob's name to "Robert", **Then** the encounter contains [Alice, Robert] and a `CombatantUpdated` event is emitted with the combatant's id, old name, and new name. + +2. **Given** an encounter with combatants [Alice, Bob] where Bob is the active combatant, **When** the user updates Bob's name to "Robert", **Then** Bob remains the active combatant (active index unchanged) and the round number is preserved. + +--- + +**Story C2 — Error feedback on invalid edit (Priority: P2)** + +A user attempts to edit a combatant that no longer exists or provides an invalid name. The system returns a clear error without modifying the encounter. + +**Acceptance Scenarios**: + +1. **Given** an encounter with combatants [Alice, Bob], **When** the user attempts to update a combatant with a non-existent id, **Then** the system returns a "combatant not found" error and the encounter is unchanged. + +2. **Given** an encounter with combatants [Alice, Bob], **When** the user attempts to update Alice's name to an empty string, **Then** the system returns an "invalid name" error and the encounter is unchanged. + +3. **Given** an encounter with combatants [Alice, Bob], **When** the user attempts to update Alice's name to a whitespace-only string, **Then** the system returns an "invalid name" error and the encounter is unchanged. + +--- + +### Clearing the Encounter + +**Story D1 — Clear encounter to start fresh (Priority: P1)** + +As a DM who has just finished a combat encounter, I want to clear the entire encounter with a single confirmed action so that I can quickly set up a new combat without manually removing each combatant one by one. + +**Acceptance Scenarios**: + +1. **Given** an encounter with multiple combatants at round 3, **When** the user activates the clear encounter action and confirms, **Then** all combatants are removed, the round number resets to 1, and the active turn index resets to 0. + +2. **Given** an encounter with a single combatant, **When** the user activates the clear encounter action and confirms, **Then** the encounter is fully cleared. + +3. **Given** an encounter has no combatants, **When** the user views the clear button, **Then** it is disabled and cannot be activated. + +--- + +**Story D2 — Inline confirmation before clearing (Priority: P1)** + +A user clicks the trash button to clear the entire encounter. Instead of a browser confirm dialog, the trash button itself transitions into a red confirm state with a checkmark icon and a scale pulse. A second click clears the encounter; otherwise the button reverts after 5 seconds or on dismiss. + +**Acceptance Scenarios**: + +1. **Given** an encounter has combatants, **When** the user clicks the clear encounter (trash) button once, **Then** the button transitions to a confirm state with a checkmark icon on a red/danger background with a scale pulse animation. + +2. **Given** the trash button is in confirm state, **When** the user clicks it again, **Then** the entire encounter is cleared. + +3. **Given** the trash button is in confirm state, **When** 5 seconds pass, the user clicks outside, or the user presses Escape, **Then** the button reverts to its original trash icon and default styling without clearing the encounter. + +4. **Given** a confirmation prompt is displayed, **When** the user cancels, **Then** the encounter remains unchanged. + +--- + +### Persistence + +**Story E1 — Encounter survives page reload (Priority: P1)** + +A user is managing a combat encounter. They accidentally refresh the page or their browser restarts. When the page reloads, the encounter is restored exactly as it was — same combatants, same active turn, same round number. + +**Acceptance Scenarios**: + +1. **Given** an encounter with combatants, active turn, and round number, **When** the user reloads the page, **Then** the encounter is restored with all state intact. + +2. **Given** an encounter that has been modified (combatant added, removed, or renamed), **When** the user reloads the page, **Then** the latest state is reflected. + +3. **Given** the user advances the turn multiple times, **When** the user reloads the page, **Then** the active turn and round number are preserved. + +--- + +**Story E2 — Fresh start with no saved data (Priority: P2)** + +A first-time user opens the application with no previously saved encounter. The application shows the default demo encounter so the user can immediately start exploring. + +**Acceptance Scenarios**: + +1. **Given** no saved encounter exists in the browser, **When** the user opens the application, **Then** the default demo encounter is displayed. + +2. **Given** saved encounter data has been manually cleared from the browser, **When** the user opens the application, **Then** the default demo encounter is displayed. + +--- + +**Story E3 — Graceful handling of corrupt data (Priority: P3)** + +Saved data may become invalid (e.g., manually edited in dev tools, schema changes between versions). The application handles this gracefully rather than crashing. + +**Acceptance Scenarios**: + +1. **Given** the saved encounter data is malformed or unparseable, **When** the user opens the application, **Then** the default demo encounter is displayed and the corrupt data is discarded. + +2. **Given** the saved data is missing required fields, **When** the user opens the application, **Then** the default demo encounter is displayed. + +--- + +## Domain Model + +### Key Entities + +- **Combatant**: An identified participant with a unique `CombatantId` (branded string), a required non-empty `name`, and optional `initiative`, `maxHp`, `currentHp`, `ac`, `conditions`, `isConcentrating`, and `creatureId` fields. +- **Encounter**: The aggregate root. Contains an ordered `readonly` list of combatants, an `activeIndex` (zero-based integer), and a `roundNumber` (positive integer, starting at 1). +> Queued Creature and Custom Creature Input entities are defined in `specs/004-bestiary/spec.md`. + +### Domain Events + +- **CombatantAdded**: Emitted on every successful AddCombatant. Carries: `combatantId`, `name`, `position` (zero-based index). +- **CombatantRemoved**: Emitted on every successful RemoveCombatant. Carries: `combatantId`, `name`. +- **CombatantUpdated**: Emitted on every successful EditCombatant. Carries: `combatantId`, `oldName`, `newName`. + +### Invariants + +- **INV-1**: An encounter MAY have zero combatants (after clearing or removing the last combatant). +- **INV-2**: If `combatants.length > 0`, `activeIndex` MUST satisfy `0 <= activeIndex < combatants.length`. If `combatants.length == 0`, `activeIndex` MUST be 0. +- **INV-3**: `roundNumber` MUST be a positive integer (>= 1) and MUST only increase during normal turn advancement. Clearing resets it to 1. +- **INV-4**: `CombatantId` values MUST be unique within an encounter. +- **INV-5**: All domain state transitions (add, remove, edit, clear) are pure functions; no I/O, randomness, or clocks. +- **INV-6**: Every successful state transition emits exactly one corresponding domain event. No silent state changes. +- **INV-7**: AddCombatant and RemoveCombatant MUST NOT change the `roundNumber`. +- **INV-8**: EditCombatant MUST NOT change `activeIndex`, `roundNumber`, or the combatant's position in the list. + +--- + +## Requirements *(mandatory)* + +### Functional Requirements + +#### FR-001 — Add: Append combatant +The domain MUST expose an AddCombatant operation that accepts an Encounter and a combatant name (plus a pre-generated `CombatantId`), and returns the updated Encounter plus emitted domain events. The new combatant MUST be appended to the end of the combatants list. + +#### FR-002 — Add: Reject invalid names +AddCombatant MUST reject empty or whitespace-only names by returning a `DomainError` without modifying state or emitting events. Name validation trims whitespace; a name that is empty after trimming is invalid. + +#### FR-003 — Add: Preserve activeIndex and roundNumber +AddCombatant MUST NOT alter the `activeIndex` or `roundNumber` of the encounter. + +#### FR-004 — Add: Unique CombatantId +AddCombatant MUST assign a unique `CombatantId` to the new combatant. Id generation is the caller's responsibility (application layer), keeping the domain function pure. + +#### FR-005 — Add: Duplicate names allowed +Duplicate combatant names are permitted. Combatants are distinguished solely by their unique `CombatantId`. + +#### FR-006 — Add: UI form +The UI MUST provide an add-combatant form accessible from the bottom bar. The search field MUST display action-oriented placeholder text (e.g., "Search creatures to add..."). + +> FR-007 through FR-013 (batch add and custom creature) are defined in `specs/004-bestiary/spec.md` (FR-007–FR-015). + +#### FR-014 — Remove: Domain operation +The domain MUST expose a RemoveCombatant operation that accepts an Encounter and a `CombatantId`, and returns the updated Encounter plus emitted domain events. + +#### FR-015 — Remove: Error on unknown ID +RemoveCombatant MUST return a domain error with code `"combatant-not-found"` when the given `CombatantId` does not match any combatant in the encounter. + +#### FR-016 — Remove: activeIndex adjustment +RemoveCombatant MUST adjust `activeIndex` according to these rules: +- Removed combatant is **after** the active one: `activeIndex` unchanged. +- Removed combatant is **before** the active one: `activeIndex` decrements by 1. +- Removed combatant **is** the active one and is not last: `activeIndex` stays at the same integer value (the next combatant in line becomes active). +- Removed combatant **is** the active one and **is last**: `activeIndex` wraps to 0. +- Last remaining combatant is removed (encounter becomes empty): `activeIndex` is set to 0. + +#### FR-017 — Remove: roundNumber preserved +RemoveCombatant MUST preserve `roundNumber` unchanged. + +#### FR-018 — Remove: UI control +The UI MUST provide a remove control for each combatant row. + +#### FR-019 — Remove: ConfirmButton +The remove control MUST use the `ConfirmButton` two-step confirmation pattern (see FR-025 through FR-030). Silent no-op on domain error (combatant already gone). + +#### FR-020 — Edit: Domain operation +The domain MUST expose an EditCombatant operation that accepts an Encounter, a `CombatantId`, and a new name, and returns the updated Encounter plus emitted domain events. + +#### FR-021 — Edit: Error on unknown ID +EditCombatant MUST return a `"combatant-not-found"` error when the provided id does not match any combatant. + +#### FR-022 — Edit: Reject invalid names +EditCombatant MUST return an `"invalid-name"` error when the new name is empty or whitespace-only. The same trimming rules as AddCombatant apply. + +#### FR-023 — Edit: Preserve position and counters +EditCombatant MUST preserve the combatant's position in the list, `activeIndex`, and `roundNumber`. Setting a name to the same value it already has is treated as a valid update; a `CombatantUpdated` event is still emitted. + +#### FR-024 — Edit: UI +The UI MUST provide an inline name-edit mechanism (click-to-edit input field) for each combatant. The updated name MUST be immediately visible after submission. + +#### FR-025 — ConfirmButton: Reusable component +The system MUST provide a reusable `ConfirmButton` component that wraps any icon button to add a two-step confirmation flow. + +#### FR-026 — ConfirmButton: Confirm state on first activation +On first activation (click, Enter, or Space), the button MUST transition to a confirm state displaying a checkmark icon on a red/danger background with a scale pulse animation. + +#### FR-027 — ConfirmButton: Auto-revert after 5 seconds +The button MUST automatically revert to its original state after 5 seconds if not confirmed. + +#### FR-028 — ConfirmButton: Cancel on outside click, Escape, or focus loss +Clicking outside the button, pressing Escape, or moving focus away MUST cancel the confirm state and revert the button. + +#### FR-029 — ConfirmButton: Execute on second activation +A second activation (click, Enter, or Space) while in confirm state MUST execute the destructive action. + +#### FR-030 — ConfirmButton: Independent state per instance +Each `ConfirmButton` instance MUST manage its confirm state independently of other instances. + +#### FR-031 — Clear: Domain operation +The domain MUST expose a ClearEncounter operation that removes all combatants, resets `roundNumber` to 1, and resets `activeIndex` to 0. + +#### FR-032 — Clear: UI button with ConfirmButton +The UI MUST provide a clear encounter button that uses the `ConfirmButton` pattern. The button MUST be disabled when the encounter has no combatants. + +#### FR-033 — Clear: Cancellation leaves state unchanged +Cancelling the confirmation (via timeout, outside click, Escape, or focus loss) MUST leave the encounter completely unchanged. + +#### FR-034 — Clear: Cleared state persisted +After clearing, the empty encounter state MUST be persisted so that a page refresh does not restore the previous encounter. + +#### FR-035 — Persistence: Save on every change +The system MUST save the full encounter state (combatants, `activeIndex`, `roundNumber`) to browser `localStorage` after every state change. + +#### FR-036 — Persistence: Restore on load +The system MUST restore the saved encounter state when the application loads, if valid saved data exists. + +#### FR-037 — Persistence: Fallback to demo encounter +The system MUST fall back to the default demo encounter when no saved data exists or saved data is invalid/corrupt. + +#### FR-038 — Persistence: No crash on storage failure +The system MUST NOT crash or show an error to the user when storage is unavailable or data is corrupt. When storage is unavailable, the application falls back to in-memory-only behavior. + +#### FR-039 — Persistence: Preserve combatant identity across reloads +The system MUST preserve combatant `CombatantId` values, names, and any other persisted fields across reloads, so that new combatants added after a reload do not collide with existing IDs. + +#### FR-040 — Domain events as values +All domain events MUST be returned as plain data values from operations, not dispatched via side effects. + +--- + +## Edge Cases + +- **Empty name**: AddCombatant and EditCombatant return a `DomainError`; state and events are unchanged. +- **Whitespace-only name**: Treated identically to empty name after trimming. +- **Adding to an empty encounter**: The new combatant becomes the first and only participant; `activeIndex` remains 0. +- **Adding during mid-round**: `activeIndex` is never shifted by an add operation. +- **Duplicate combatant names**: Permitted. Combatants are distinguished by `CombatantId`. +- **Removing the last combatant**: Encounter becomes empty; `activeIndex` is set to 0. +- **Removing with unknown ID**: Returns `"combatant-not-found"` error; state unchanged. Removing the same ID twice: second call returns an error. +- **Removing from empty encounter**: Covered by the unknown-ID error (no IDs exist). +- **Editing a combatant to the same name**: Valid; `CombatantUpdated` event is still emitted. +- **Editing a combatant in an empty encounter**: Returns `"combatant-not-found"` error. +- **Clearing an already empty encounter**: The clear button is disabled; no operation is executed. +- **Clearing and reloading**: The empty (cleared) state is persisted; the previous encounter is not restored. +- **Storage quota exceeded**: Persistence silently fails; current in-memory session continues normally. +- **Multiple browser tabs**: MVP baseline does not include cross-tab synchronization. Each tab operates independently; the last tab to save wins. +> Batch add and custom creature edge cases are defined in `specs/004-bestiary/spec.md`. +- **ConfirmButton: rapid triple-click**: First click enters confirm state; second executes the action; subsequent clicks are no-ops. +- **ConfirmButton: component unmounts in confirm state**: The auto-revert timer MUST be cleaned up to prevent memory leaks or stale state updates. +- **ConfirmButton: two instances in confirm state simultaneously**: Each manages its own state independently. +- **ConfirmButton: combatant row re-renders while in confirm state**: Confirm state persists through re-renders as long as combatant identity is stable. + +--- + +## Success Criteria *(mandatory)* + +- **SC-001**: All add-combatant acceptance scenarios pass as deterministic, pure-function tests with no I/O dependencies. +- **SC-002**: Adding a combatant to an encounter preserves all existing combatants, their order, `activeIndex`, and `roundNumber` unchanged. +- **SC-003**: All six remove-combatant acceptance scenarios pass as automated tests covering every `activeIndex` adjustment rule. +- **SC-004**: The round number never changes as a result of a remove operation. +- **SC-005**: Users can rename any combatant in the encounter in a single action without disrupting turn order, active combatant, or round number. +- **SC-006**: Invalid edit attempts (missing combatant, empty or whitespace-only name) produce a domain error with no state change and no emitted events. +- **SC-007**: All destructive actions (remove combatant, clear encounter) require exactly two deliberate user interactions to execute, eliminating single-click accidental mutations. +- **SC-008**: The `ConfirmButton` confirm state auto-reverts reliably after 5 seconds. All confirmation flows are fully operable via keyboard alone. +> SC-009 and SC-010 (batch add and custom creature success criteria) are defined in `specs/004-bestiary/spec.md`. +- **SC-011**: Users can reload the page and see their encounter fully restored, with zero data loss. +- **SC-012**: First-time users see the demo encounter immediately on first visit with no extra steps. +- **SC-013**: 100% of corrupt or missing data scenarios result in a usable application (demo encounter displayed), never a crash or blank screen. +- **SC-014**: After clearing, the encounter tracker displays an empty state with round and turn counters at their initial values, and this state persists across page refreshes. +- **SC-015**: The domain module has zero imports from the application, adapter, or UI layers (layer boundary compliance verified by automated check). + +--- + +## Assumptions + +- `CombatantId` generation is the caller's responsibility (application layer), keeping domain functions pure and deterministic. +- Name validation trims whitespace; a name that is empty after trimming is invalid. +- No uniqueness constraint on combatant names — multiple combatants may share the same name. +- Clearing results in an empty encounter state (no combatants, `roundNumber` 1, `activeIndex` 0). The user will then add new combatants using the existing add-combatant flow. +- MVP baseline does not include undo/restore functionality after clearing or removing. Once confirmed, the action is final. +- MVP baseline does not include encounter history or the ability to save/archive encounters before clearing. +- A single `localStorage` key is sufficient for the MVP (one encounter at a time). +- Cross-tab synchronization is not required for the MVP baseline. +- The `ConfirmButton` 5-second timeout is a fixed value and is not configurable in the MVP baseline. +- The `Check` icon from the Lucide icon library is used for the `ConfirmButton` confirm state. +- The inline name-edit mechanism (click-to-edit input field) is used for combatant renaming. More complex UX (modal dialogs, undo/redo) is not in the MVP baseline. diff --git a/specs/002-add-combatant/checklists/requirements.md b/specs/002-add-combatant/checklists/requirements.md deleted file mode 100644 index c5c4fba..0000000 --- a/specs/002-add-combatant/checklists/requirements.md +++ /dev/null @@ -1,35 +0,0 @@ -# Specification Quality Checklist: Add Combatant - -**Purpose**: Validate specification completeness and quality before proceeding to planning -**Created**: 2026-03-03 -**Feature**: [spec.md](../spec.md) - -## Content Quality - -- [x] No implementation details (languages, frameworks, APIs) -- [x] Focused on user value and business needs -- [x] Written for non-technical stakeholders -- [x] All mandatory sections completed - -## Requirement Completeness - -- [x] No [NEEDS CLARIFICATION] markers remain -- [x] Requirements are testable and unambiguous -- [x] Success criteria are measurable -- [x] Success criteria are technology-agnostic (no implementation details) -- [x] All acceptance scenarios are defined -- [x] Edge cases are identified -- [x] Scope is clearly bounded -- [x] Dependencies and assumptions identified - -## Feature Readiness - -- [x] All functional requirements have clear acceptance criteria -- [x] User scenarios cover primary flows -- [x] Feature meets measurable outcomes defined in Success Criteria -- [x] No implementation details leak into specification - -## Notes - -- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`. -- Assumption documented: CombatantId is passed in rather than generated internally, keeping domain pure. diff --git a/specs/002-add-combatant/data-model.md b/specs/002-add-combatant/data-model.md deleted file mode 100644 index 185480b..0000000 --- a/specs/002-add-combatant/data-model.md +++ /dev/null @@ -1,77 +0,0 @@ -# Data Model: Add Combatant - -**Feature**: 002-add-combatant -**Date**: 2026-03-03 - -## Entities - -### Combatant (existing, unchanged) - -| Field | Type | Constraints | -|-------|------|-------------| -| id | CombatantId (branded string) | Unique, required | -| name | string | Non-empty after trimming, required | - -### Encounter (existing, unchanged) - -| Field | Type | Constraints | -|-------|------|-------------| -| combatants | readonly Combatant[] | Ordered list, may be empty | -| activeIndex | number | 0 <= activeIndex < combatants.length (or 0 if empty) | -| roundNumber | number | Positive integer >= 1, only increases | - -## Domain Events - -### CombatantAdded (new) - -| Field | Type | Description | -|-------|------|-------------| -| type | "CombatantAdded" (literal) | Discriminant for the DomainEvent union | -| combatantId | CombatantId | Id of the newly added combatant | -| name | string | Name of the newly added combatant | -| position | number | Zero-based index where the combatant was placed | - -## State Transitions - -### AddCombatant - -**Input**: Encounter + CombatantId + name (string) - -**Preconditions**: -- Name must be non-empty after trimming - -**Transition**: -- New combatant `{ id, name: trimmedName }` appended to end of combatants list -- activeIndex unchanged -- roundNumber unchanged - -**Postconditions**: -- combatants.length increased by 1 -- New combatant is at index `combatants.length - 1` -- All existing combatants preserve their order and index positions -- INV-2 satisfied (activeIndex still valid for the now-larger list) - -**Events emitted**: Exactly one `CombatantAdded` - -**Error cases**: -- Empty or whitespace-only name → DomainError `{ code: "invalid-name" }` - -## Function Signatures - -### Domain Layer - -``` -addCombatant(encounter, id, name) → { encounter, events } | DomainError -``` - -### Application Layer - -``` -addCombatantUseCase(store, id, name) → DomainEvent[] | DomainError -``` - -## Validation Rules - -| Rule | Layer | Error Code | -|------|-------|------------| -| Name non-empty after trim | Domain | invalid-name | diff --git a/specs/002-add-combatant/plan.md b/specs/002-add-combatant/plan.md deleted file mode 100644 index fb0606c..0000000 --- a/specs/002-add-combatant/plan.md +++ /dev/null @@ -1,76 +0,0 @@ -# Implementation Plan: Add Combatant - -**Branch**: `002-add-combatant` | **Date**: 2026-03-03 | **Spec**: [spec.md](./spec.md) -**Input**: Feature specification from `/specs/002-add-combatant/spec.md` - -## Summary - -Add a pure domain function `addCombatant` that appends a new combatant to the end of an encounter's combatant list without altering the active turn or round. The feature follows the same pattern as `advanceTurn`: a pure function returning updated state plus domain events, with an application-layer use case and a React adapter hook. - -## Technical Context - -**Language/Version**: TypeScript 5.x (strict mode, verbatimModuleSyntax) -**Primary Dependencies**: None for domain; React 19 for web adapter -**Storage**: In-memory (React state via hook) -**Testing**: Vitest -**Target Platform**: Browser (Vite dev server) -**Project Type**: Monorepo (pnpm workspaces): domain library + application library + web app -**Performance Goals**: N/A (pure synchronous function) -**Constraints**: Domain must remain pure — no I/O, no randomness -**Scale/Scope**: Single-user local app - -## Constitution Check - -*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* - -| Principle | Status | Notes | -|-----------|--------|-------| -| I. Deterministic Domain Core | PASS | `addCombatant` is a pure function. CombatantId is passed in as input, not generated internally. | -| II. Layered Architecture | PASS | Domain function in `packages/domain`, use case in `packages/application`, hook in `apps/web`. No reverse imports. | -| III. Agent Boundary | PASS | No agent layer involvement in this feature. | -| IV. Clarification-First | PASS | Spec has no NEEDS CLARIFICATION markers. Key assumption (id passed in) is documented. | -| V. Escalation Gates | PASS | Implementation stays within spec scope. | -| VI. MVP Baseline Language | PASS | Out-of-scope items use "MVP baseline does not include". | -| VII. No Gameplay Rules | PASS | No gameplay mechanics in constitution. | - -All gates pass. No violations to justify. - -## Project Structure - -### Documentation (this feature) - -```text -specs/002-add-combatant/ -├── plan.md # This file -├── research.md # Phase 0 output -├── data-model.md # Phase 1 output -├── quickstart.md # Phase 1 output -└── tasks.md # Phase 2 output (via /speckit.tasks) -``` - -### Source Code (repository root) - -```text -packages/domain/src/ -├── types.ts # Encounter, Combatant, CombatantId (existing) -├── events.ts # DomainEvent union (add CombatantAdded) -├── add-combatant.ts # NEW: addCombatant pure function -├── advance-turn.ts # Existing (unchanged) -├── index.ts # Re-exports (add new exports) -└── __tests__/ - ├── advance-turn.test.ts # Existing (unchanged) - └── add-combatant.test.ts # NEW: acceptance + invariant tests - -packages/application/src/ -├── ports.ts # EncounterStore (unchanged) -├── add-combatant-use-case.ts # NEW: orchestrates addCombatant -├── advance-turn-use-case.ts # Existing (unchanged) -└── index.ts # Re-exports (add new exports) - -apps/web/src/ -├── App.tsx # Update: add combatant input + button -└── hooks/ - └── use-encounter.ts # Update: expose addCombatant callback -``` - -**Structure Decision**: Follows the established monorepo layout. Each domain operation gets its own file (matching `advance-turn.ts` pattern). No new packages or directories needed beyond the existing structure. diff --git a/specs/002-add-combatant/quickstart.md b/specs/002-add-combatant/quickstart.md deleted file mode 100644 index e0b85cb..0000000 --- a/specs/002-add-combatant/quickstart.md +++ /dev/null @@ -1,47 +0,0 @@ -# Quickstart: Add Combatant - -**Feature**: 002-add-combatant - -## Prerequisites - -```bash -pnpm install -``` - -## Development - -```bash -pnpm test:watch # Watch all tests -pnpm vitest run packages/domain/src/__tests__/add-combatant.test.ts # Run feature tests -pnpm --filter web dev # Dev server at localhost:5173 -``` - -## Merge Gate - -```bash -pnpm check # Must pass before commit (format + lint + typecheck + test) -``` - -## Implementation Order - -1. **Domain event** — Add `CombatantAdded` to `events.ts` and the `DomainEvent` union -2. **Domain function** — Create `add-combatant.ts` with the pure `addCombatant` function -3. **Domain exports** — Update `index.ts` to re-export new items -4. **Domain tests** — Create `add-combatant.test.ts` with all 6 acceptance scenarios + invariant checks -5. **Application use case** — Create `add-combatant-use-case.ts` -6. **Application exports** — Update `index.ts` to re-export -7. **Web hook** — Update `use-encounter.ts` to expose `addCombatant` callback -8. **Web UI** — Update `App.tsx` with name input and add button - -## Key Files - -| File | Action | Purpose | -|------|--------|---------| -| `packages/domain/src/events.ts` | Edit | Add CombatantAdded event type | -| `packages/domain/src/add-combatant.ts` | Create | Pure addCombatant function | -| `packages/domain/src/index.ts` | Edit | Export new items | -| `packages/domain/src/__tests__/add-combatant.test.ts` | Create | Acceptance + invariant tests | -| `packages/application/src/add-combatant-use-case.ts` | Create | Use case orchestration | -| `packages/application/src/index.ts` | Edit | Export new use case | -| `apps/web/src/hooks/use-encounter.ts` | Edit | Add combatant hook callback | -| `apps/web/src/App.tsx` | Edit | Name input + add button UI | diff --git a/specs/002-add-combatant/research.md b/specs/002-add-combatant/research.md deleted file mode 100644 index de25060..0000000 --- a/specs/002-add-combatant/research.md +++ /dev/null @@ -1,40 +0,0 @@ -# Research: Add Combatant - -**Feature**: 002-add-combatant -**Date**: 2026-03-03 - -## Research Summary - -No NEEDS CLARIFICATION items existed in the technical context. The feature is straightforward and follows established patterns. Research focused on confirming existing patterns and the one key design decision. - -## Decision 1: CombatantId Generation Strategy - -**Decision**: CombatantId is passed into the domain function as an argument, not generated internally. - -**Rationale**: The domain layer must remain pure and deterministic (Constitution Principle I). Generating IDs internally would require either randomness (UUID) or side effects (counter with mutable state), both of which violate purity. By accepting the id as input, `addCombatant(encounter, id, name)` is a pure function: same inputs always produce the same output. - -**Alternatives considered**: -- Generate UUID inside domain: Violates deterministic core principle. Tests would be non-deterministic. -- Pass an id-generator function: Adds unnecessary complexity. The application layer can generate the id and pass it in. - -**Who generates the id**: The application layer (use case) or adapter layer (hook) generates the CombatantId before calling the domain function. This matches how `createEncounter` already works — callers construct `Combatant` objects with pre-assigned ids. - -## Decision 2: Function Signature Pattern - -**Decision**: Follow the `advanceTurn` pattern — standalone pure function returning a success result or DomainError. - -**Rationale**: Consistency with the existing codebase. `advanceTurn` returns `AdvanceTurnSuccess | DomainError`, so `addCombatant` will return `AddCombatantSuccess | DomainError` with the same shape: `{ encounter, events }`. - -**Alternatives considered**: -- Method on an Encounter class: Project uses plain interfaces and free functions, not classes. -- Mutating the encounter in place: Violates immutability convention (all fields are `readonly`). - -## Decision 3: Name Validation Approach - -**Decision**: Trim whitespace, then reject empty strings. The domain function validates the name. - -**Rationale**: Name validation is a domain rule (what constitutes a valid combatant name), so it belongs in the domain layer. Trimming before checking prevents whitespace-only names from slipping through. - -**Alternatives considered**: -- Validate in application layer: Would allow invalid data to reach domain if called from a different adapter. Domain should protect its own invariants. -- Accept any string: Would allow empty-name combatants, violating spec FR-004. diff --git a/specs/002-add-combatant/spec.md b/specs/002-add-combatant/spec.md deleted file mode 100644 index 13e06be..0000000 --- a/specs/002-add-combatant/spec.md +++ /dev/null @@ -1,161 +0,0 @@ -# Feature Specification: Add Combatant - -**Feature Branch**: `002-add-combatant` -**Created**: 2026-03-03 -**Status**: Draft -**Input**: User description: "let us add a spec for the option to add a combatant to the encounter. a new combatant is added to the end of the list." - -## User Scenarios & Testing *(mandatory)* - -### User Story 1 - Add Combatant to Encounter (Priority: P1) - -A game master adds a new combatant to an existing encounter. The new -combatant is appended to the end of the initiative order. This allows -late-joining participants or newly discovered enemies to enter combat. - -**Why this priority**: Adding combatants is the foundational mutation -for populating an encounter. Without it, the encounter has no -participants and no other feature (turn advancement, removal) is useful. - -**Independent Test**: Can be fully tested as a pure state transition -with no I/O, persistence, or UI. Given an Encounter value and an -AddCombatant action with a name, assert the resulting Encounter value -and emitted domain events. - -**Acceptance Scenarios**: - -1. **Given** an empty encounter (no combatants, activeIndex 0, - roundNumber 1), - **When** AddCombatant with name "Gandalf", - **Then** combatants is [Gandalf], activeIndex is 0, - roundNumber is 1, - and a CombatantAdded event is emitted with the new combatant's - id and name "Gandalf" and position 0. - -2. **Given** an encounter with combatants [A, B], activeIndex 0, - roundNumber 1, - **When** AddCombatant with name "C", - **Then** combatants is [A, B, C], activeIndex is 0, - roundNumber is 1, - and a CombatantAdded event is emitted with position 2. - -3. **Given** an encounter with combatants [A, B, C], activeIndex 2, - roundNumber 3, - **When** AddCombatant with name "D", - **Then** combatants is [A, B, C, D], activeIndex is 2, - roundNumber is 3, - and a CombatantAdded event is emitted with position 3. - The active combatant does not change. - -4. **Given** an encounter with combatants [A], - **When** AddCombatant is applied twice with names "B" then "C", - **Then** combatants is [A, B, C] in that order. - Each operation emits its own CombatantAdded event. - -5. **Given** an encounter with combatants [A, B], - **When** AddCombatant with an empty name "", - **Then** the operation MUST fail with a validation error. - No events are emitted. State is unchanged. - -6. **Given** an encounter with combatants [A, B], - **When** AddCombatant with a whitespace-only name " ", - **Then** the operation MUST fail with a validation error. - No events are emitted. State is unchanged. - ---- - -### Edge Cases - -- Empty name or whitespace-only name: AddCombatant MUST return a - DomainError (no state change, no events). -- Adding to an empty encounter: the new combatant becomes the first - and only participant; activeIndex remains 0. -- Adding during mid-round: the activeIndex must not shift; the - currently active combatant stays active. -- Duplicate names: allowed. Combatants are distinguished by their - unique id, not by name. - -## Domain Model *(mandatory)* - -### Key Entities - -- **Combatant**: An identified participant in the encounter with a - unique CombatantId (branded string) and a name (non-empty string). -- **Encounter**: The aggregate root. Contains an ordered list of - combatants, an activeIndex pointing to the current combatant, and - a roundNumber (positive integer, starting at 1). - -### Domain Events - -- **CombatantAdded**: Emitted on every successful AddCombatant. - Carries: combatantId, name, position (zero-based index where the - combatant was inserted). - -### Invariants - -- **INV-1** (preserved): An encounter MAY have zero combatants. -- **INV-2** (preserved): If combatants.length > 0, activeIndex MUST - satisfy 0 <= activeIndex < combatants.length. If - combatants.length == 0, activeIndex MUST be 0. -- **INV-3** (preserved): roundNumber MUST be a positive integer - (>= 1) and MUST only increase. -- **INV-4**: AddCombatant MUST be a pure function of the current - encounter state and the input name. Given identical input, output - MUST be identical (except for id generation — see Assumptions). -- **INV-5**: Every successful AddCombatant MUST emit exactly one - CombatantAdded event. No silent state changes. -- **INV-6**: AddCombatant MUST NOT change the activeIndex or - roundNumber of the encounter. -- **INV-7**: The new combatant MUST be appended to the end of the - combatants list (last position). - -## Requirements *(mandatory)* - -### Functional Requirements - -- **FR-001**: The domain MUST expose an AddCombatant operation that - accepts an Encounter and a combatant name, and returns the updated - Encounter state plus emitted domain events. -- **FR-002**: AddCombatant MUST append the new combatant to the end - of the combatants list. -- **FR-003**: AddCombatant MUST assign a unique CombatantId to the - new combatant. -- **FR-004**: AddCombatant MUST reject empty or whitespace-only names - by returning a DomainError without modifying state or emitting - events. -- **FR-005**: AddCombatant MUST NOT alter the activeIndex or - roundNumber of the encounter. -- **FR-006**: Domain events MUST be returned as values from the - operation, not dispatched via side effects. - -### Out of Scope (MVP baseline does not include) - -- Removing combatants from an encounter -- Reordering combatants after adding -- Initiative score or automatic sorting -- Combatant attributes beyond name (HP, conditions, stats) -- Maximum combatant count limits -- Persistence, serialization, or storage -- UI or any adapter layer - -## Assumptions - -- CombatantId generation is the caller's responsibility (passed in or - generated by the application layer), keeping the domain function - pure and deterministic. The domain function will accept a - CombatantId as part of its input rather than generating one - internally. -- Name validation trims whitespace; a name that is empty after - trimming is invalid. - -## Success Criteria *(mandatory)* - -### Measurable Outcomes - -- **SC-001**: All 6 acceptance scenarios pass as deterministic, - pure-function tests with no I/O dependencies. -- **SC-002**: Invariants INV-1 through INV-7 are verified by tests. -- **SC-003**: The domain module has zero imports from application, - adapter, or agent layers (layer boundary compliance). -- **SC-004**: Adding a combatant to an encounter preserves all - existing combatants and their order unchanged. diff --git a/specs/002-add-combatant/tasks.md b/specs/002-add-combatant/tasks.md deleted file mode 100644 index 9e1231f..0000000 --- a/specs/002-add-combatant/tasks.md +++ /dev/null @@ -1,129 +0,0 @@ -# Tasks: Add Combatant - -**Input**: Design documents from `/specs/002-add-combatant/` -**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md - -**Tests**: Included — spec success criteria SC-001 and SC-002 require all acceptance scenarios and invariants to be verified by tests. - -**Organization**: Single user story (P1). Tasks follow the established `advanceTurn` pattern across all three layers. - -## Format: `[ID] [P?] [Story] Description` - -- **[P]**: Can run in parallel (different files, no dependencies) -- **[Story]**: Which user story this task belongs to (e.g., US1) -- Include exact file paths in descriptions - ---- - -## Phase 1: Foundational (Domain Event) - -**Purpose**: Add the CombatantAdded event type that all layers depend on - -- [x] T001 Add CombatantAdded event interface and extend DomainEvent union in packages/domain/src/events.ts - -**Checkpoint**: CombatantAdded event type available for import - ---- - -## Phase 2: User Story 1 - Add Combatant to Encounter (Priority: P1) 🎯 MVP - -**Goal**: A game master can add a new combatant to an existing encounter. The combatant is appended to the end of the initiative list without changing the active turn or round. - -**Independent Test**: Call `addCombatant` with an Encounter, a CombatantId, and a name. Assert the returned Encounter has the new combatant at the end, activeIndex and roundNumber unchanged, and a CombatantAdded event emitted. - -### Domain Layer - -- [x] T002 [US1] Create addCombatant pure function in packages/domain/src/add-combatant.ts -- [x] T003 [US1] Export addCombatant and AddCombatantSuccess from packages/domain/src/index.ts - -### Domain Tests - -- [x] T004 [US1] Create acceptance tests (6 scenarios) and invariant tests (INV-1 through INV-7) in packages/domain/src/__tests__/add-combatant.test.ts - -### Application Layer - -- [x] T005 [P] [US1] Create addCombatantUseCase in packages/application/src/add-combatant-use-case.ts -- [x] T006 [US1] Export addCombatantUseCase from packages/application/src/index.ts - -### Web Adapter - -- [x] T007 [US1] Add addCombatant callback to useEncounter hook in apps/web/src/hooks/use-encounter.ts -- [x] T008 [US1] Add combatant name input and add button to apps/web/src/App.tsx - -**Checkpoint**: All 6 acceptance scenarios pass. User can type a name and add a combatant via the UI. `pnpm check` passes. - ---- - -## Phase 3: Polish & Cross-Cutting Concerns - -- [x] T009 Run pnpm check (format + lint + typecheck + test) and fix any issues -- [x] T010 Verify layer boundary compliance (domain has no outer-layer imports) - ---- - -## Dependencies & Execution Order - -### Phase Dependencies - -- **Foundational (Phase 1)**: No dependencies — start immediately -- **User Story 1 (Phase 2)**: Depends on T001 (CombatantAdded event type) -- **Polish (Phase 3)**: Depends on all Phase 2 tasks - -### Within User Story 1 - -``` -T001 (event type) - ├── T002 (domain function) → T003 (domain exports) → T004 (domain tests) - └── T005 (use case) ──────→ T006 (app exports) → T007 (hook) → T008 (UI) -``` - -- T002 depends on T001 (needs CombatantAdded type) -- T003 depends on T002 (exports the new function) -- T004 depends on T003 (tests import from index) -- T005 depends on T003 (use case imports domain function) — can run in parallel with T004 -- T006 depends on T005 -- T007 depends on T006 -- T008 depends on T007 - -### Parallel Opportunities - -- T004 (domain tests) and T005 (use case) can run in parallel after T003 -- T009 and T010 can run in parallel - ---- - -## Parallel Example: After T003 - -``` -# These two tasks touch different packages and can run in parallel: -T004: "Acceptance + invariant tests in packages/domain/src/__tests__/add-combatant.test.ts" -T005: "Use case in packages/application/src/add-combatant-use-case.ts" -``` - ---- - -## Implementation Strategy - -### MVP (This Feature) - -1. T001: Add event type (foundation) -2. T002–T003: Domain function + exports -3. T004 + T005 in parallel: Tests + use case -4. T006–T008: Application exports → hook → UI -5. T009–T010: Verify everything passes - -### Validation - -After T004: All 6 acceptance scenarios pass as pure-function tests -After T008: UI allows adding combatants by name -After T009: `pnpm check` passes clean (merge gate) - ---- - -## Notes - -- Follow the `advanceTurn` pattern for function signature, result type, and error handling -- CombatantId is passed in as input (generated by caller), not created inside domain -- Name is trimmed then validated; empty after trim returns DomainError with code "invalid-name" -- Commit after each task or logical group -- Total: 10 tasks (1 foundational + 7 US1 + 2 polish) diff --git a/specs/002-turn-tracking/spec.md b/specs/002-turn-tracking/spec.md new file mode 100644 index 0000000..8d7e143 --- /dev/null +++ b/specs/002-turn-tracking/spec.md @@ -0,0 +1,319 @@ +# Feature Specification: Turn Tracking + +**Feature Branch**: `002-turn-tracking` +**Created**: 2026-03-03 +**Status**: Implemented + +--- + +## Overview + +Turn Tracking covers all aspects of managing the flow of combat: advancing and retreating through combatants in initiative order, incrementing and decrementing the round counter at round boundaries, sorting combatants by initiative value, and presenting the top bar UI with navigation controls, round display, and active combatant display. + +--- + +## Domain Model + +### Key Entities + +- **Combatant** — An identified participant in the encounter. Carries an optional integer `initiative` value that determines position in turn order. +- **Encounter** — The aggregate root. Contains an ordered list of combatants (sorted by initiative descending, unset last), an `activeIndex` pointing to the current combatant, and a `roundNumber` (positive integer, starting at 1). + +### Domain Events + +- **TurnAdvanced** — Emitted on every successful AdvanceTurn. Carries: `previousCombatantId`, `newCombatantId`, `roundNumber`. +- **RoundAdvanced** — Emitted when advancing crosses the end of the combatant list. Carries: `newRoundNumber`. +- **TurnRetreated** — Emitted on every successful RetreatTurn. Carries: `previousCombatantId`, `newCombatantId`, `roundNumber`. +- **RoundRetreated** — Emitted when retreating crosses a round boundary backward. Carries: `newRoundNumber`. +- **InitiativeSet** — Emitted when a combatant's initiative value is set or changed. + +When a round boundary is crossed, the corresponding turn event (TurnAdvanced or TurnRetreated) MUST be emitted first, followed by the round event (RoundAdvanced or RoundRetreated). This emission order is part of the observable domain contract. + +### Invariants + +- **INV-1**: An encounter MAY have zero combatants (empty encounter is valid aggregate state). AdvanceTurn and RetreatTurn on an empty encounter MUST return a DomainError with no state change and no events. +- **INV-2**: If `combatants.length > 0`, `activeIndex` MUST satisfy `0 <= activeIndex < combatants.length`. If `combatants.length == 0`, `activeIndex` MUST be 0. +- **INV-3**: `roundNumber` MUST be a positive integer (>= 1). It MUST only increase on AdvanceTurn and only decrease on RetreatTurn; it MUST never drop below 1. +- **INV-4**: AdvanceTurn and RetreatTurn MUST be pure functions of the current encounter state. Given identical input, output MUST be identical. +- **INV-5**: Every successful AdvanceTurn or RetreatTurn MUST emit at least one domain event (TurnAdvanced or TurnRetreated respectively). No silent state changes. +- **INV-6**: The initiative sort MUST be stable — combatants with equal initiative (or multiple combatants with no initiative) retain their relative insertion order. +- **INV-7**: The active combatant's identity MUST be preserved through any initiative-driven reorder — the active turn tracks the combatant by identity, not by index position. + +--- + +## User Scenarios & Testing *(mandatory)* + +### Advancing Turns + +#### Story A1 — Advance to the Next Combatant (Priority: P1) + +As a game master running an encounter, I want to advance the turn to the next combatant in initiative order so that play moves forward through the encounter. + +**Acceptance Scenarios**: + +1. **Given** an encounter with combatants [A, B, C], activeIndex 0, roundNumber 1, **When** AdvanceTurn, **Then** activeIndex is 1, roundNumber is 1, and a TurnAdvanced event is emitted with previousCombatantId A, newCombatantId B, roundNumber 1. +2. **Given** an encounter with combatants [A, B, C], activeIndex 1, roundNumber 1, **When** AdvanceTurn, **Then** activeIndex is 2, roundNumber is 1, and a TurnAdvanced event is emitted with previousCombatantId B, newCombatantId C, roundNumber 1. +3. **Given** an encounter with combatants [A, B], activeIndex 0, roundNumber 1, **When** AdvanceTurn is applied twice in sequence, **Then** after the first: activeIndex 1, roundNumber 1; after the second: activeIndex 0, roundNumber 2. +4. **Given** an encounter with combatants [A, B, C], activeIndex 0, roundNumber 1, **When** AdvanceTurn is applied three times, **Then** activeIndex returns to 0 and roundNumber is 2. + +#### Story A2 — Round Increment on Wrap (Priority: P1) + +As a game master, I want the round number to increment automatically when the last combatant's turn ends so that I always know which round of combat I am in. + +**Acceptance Scenarios**: + +1. **Given** an encounter with combatants [A, B, C], activeIndex 2, roundNumber 1, **When** AdvanceTurn, **Then** activeIndex is 0, roundNumber is 2, and events are emitted in order: TurnAdvanced (previousCombatantId C, newCombatantId A, roundNumber 2) then RoundAdvanced (newRoundNumber 2). +2. **Given** an encounter with combatants [A, B, C], activeIndex 2, roundNumber 5, **When** AdvanceTurn, **Then** activeIndex is 0, roundNumber is 6, and events are emitted in order: TurnAdvanced then RoundAdvanced (verifies round increment is not hardcoded to 2). +3. **Given** an encounter with a single combatant [A], activeIndex 0, roundNumber 1, **When** AdvanceTurn, **Then** activeIndex is 0, roundNumber is 2, and events are emitted in order: TurnAdvanced (previousCombatantId A, newCombatantId A, roundNumber 2) then RoundAdvanced (newRoundNumber 2). + +#### Story A3 — AdvanceTurn on Empty Encounter (Priority: P1) + +As a developer, I want AdvanceTurn to fail safely on an empty encounter so that no invalid state is ever produced. + +**Acceptance Scenarios**: + +1. **Given** an encounter with an empty combatant list, **When** AdvanceTurn, **Then** the operation MUST fail with an invalid-encounter error. No events are emitted. State is unchanged. + +--- + +### Retreating Turns + +#### Story R1 — Go Back to the Previous Turn (Priority: P1) + +As a game master running a combat encounter, I want to go back to the previous combatant's turn so that I can correct mistakes (e.g., a forgotten action, an incorrectly skipped combatant) without restarting the encounter. + +**Acceptance Scenarios**: + +1. **Given** an encounter with combatants [A, B, C], activeIndex 1, roundNumber 1, **When** RetreatTurn, **Then** activeIndex is 0, roundNumber is 1, and a TurnRetreated event is emitted with previousCombatantId B, newCombatantId A, roundNumber 1. +2. **Given** an encounter with a single combatant [A], activeIndex 0, roundNumber 3, **When** RetreatTurn, **Then** activeIndex is 0, roundNumber is 2, and events are emitted in order: TurnRetreated then RoundRetreated (newRoundNumber 2). + +#### Story R2 — Round Decrement on Wrap Backward (Priority: P1) + +As a game master, I want the round number to decrement when retreating past the first combatant so that the encounter state accurately reflects where I am in the timeline. + +**Acceptance Scenarios**: + +1. **Given** an encounter with combatants [A, B, C], activeIndex 0, roundNumber 2, **When** RetreatTurn, **Then** activeIndex is 2, roundNumber is 1, and events are emitted in order: TurnRetreated (previousCombatantId A, newCombatantId C, roundNumber 1) then RoundRetreated (newRoundNumber 1). + +#### Story R3 — Retreat Blocked at Encounter Start (Priority: P1) + +As a game master, I want the Previous Turn action to fail when I am at the very beginning of the encounter so that round 1 / first combatant is the earliest reachable state. + +**Acceptance Scenarios**: + +1. **Given** an encounter with combatants [A, B, C], activeIndex 0, roundNumber 1, **When** RetreatTurn, **Then** the operation MUST fail with an error. No events are emitted. State is unchanged. +2. **Given** an encounter with a single combatant [A], activeIndex 0, roundNumber 1, **When** RetreatTurn, **Then** the operation MUST fail with an error. No events are emitted. State is unchanged. + +#### Story R4 — RetreatTurn on Empty Encounter (Priority: P1) + +As a developer, I want RetreatTurn to fail safely on an empty encounter so that no invalid state is ever produced. + +**Acceptance Scenarios**: + +1. **Given** an encounter with an empty combatant list, **When** RetreatTurn, **Then** the operation MUST fail with an invalid-encounter error. No events are emitted. State is unchanged. + +--- + +### Round Tracking + +#### Story RD1 — Round Number Display (Priority: P1) + +As a game master, I want the current round number to always be visible at the top of the tracker so that I never lose track of which round of combat I am in. + +**Acceptance Scenarios**: + +1. **Given** an active encounter in Round 2, **When** the user views the top bar, **Then** the round badge shows "R2" (or equivalent compact format) as a visually distinct element. +2. **Given** the user advances the turn and the round increments from 3 to 4, **Then** the round badge updates to the new round number immediately without layout shift. +3. **Given** an encounter with no combatants, **When** viewing the top bar, **Then** the round badge still shows the current round number. + +--- + +### Turn Order (Initiative Sorting) + +#### Story TO1 — Automatic Ordering by Initiative (Priority: P1) + +As a game master, I want the encounter to automatically sort combatants from highest to lowest initiative whenever initiative values are set or changed so that I do not have to manually reorder them. + +**Acceptance Scenarios**: + +1. **Given** combatants A (initiative 20), B (initiative 5), C (initiative 15), **When** all initiatives are set, **Then** the combatant order is A (20), C (15), B (5). +2. **Given** combatants in order A (20), C (15), B (5), **When** B's initiative is changed to 25, **Then** the order becomes B (25), A (20), C (15). +3. **Given** combatants A (initiative 10) and B (initiative 10) with the same value, **Then** their relative order is preserved (stable sort — the combatant who was added or set first stays ahead). + +#### Story TO2 — Combatants Without Initiative (Priority: P2) + +As a game master, I want combatants who have not had their initiative set to appear at the end of the turn order so that the encounter remains usable while I am still entering initiative values. + +**Acceptance Scenarios**: + +1. **Given** combatants A (initiative 15), B (no initiative), C (initiative 10), **Then** the order is A (15), C (10), B (no initiative). +2. **Given** combatants A (no initiative) and B (no initiative), **Then** their relative order is preserved from when they were added. +3. **Given** combatant A (no initiative), **When** initiative is set to 12, **Then** A moves to its correct sorted position among combatants that have initiative values. +4. **Given** combatant A (initiative 15), **When** the user clears A's initiative, **Then** A moves to the end of the turn order. + +#### Story TO3 — Active Turn Preserved During Reorder (Priority: P2) + +As a game master mid-encounter, I want the active combatant's turn to be preserved when initiative changes cause a reorder so that I do not lose track of whose turn it is. + +**Acceptance Scenarios**: + +1. **Given** it is combatant B's turn (activeIndex points to B), **When** combatant A's initiative is changed causing a reorder, **Then** the active turn still points to combatant B. +2. **Given** it is combatant A's turn, **When** combatant A's own initiative is changed causing a reorder, **Then** the active turn still points to combatant A. + +--- + +### Top Bar Display + +#### Story TB1 — Scanning Round and Combatant at a Glance (Priority: P1) + +As a game master running an encounter, I want the round number and current combatant displayed as distinct, visually separated elements so I can instantly identify both without parsing a combined string. + +**Acceptance Scenarios**: + +1. **Given** an active encounter in Round 2 with "Goblin" as the active combatant, **When** the user views the top bar, **Then** "R2" appears as a muted badge/pill near the left side and "Goblin" appears as a prominent centered label, with no dash or combined string. +2. **Given** an active encounter in Round 1 at the first combatant, **When** the encounter starts, **Then** the round badge shows the round number and the center displays the first combatant's name as separate visual elements. +3. **Given** the user advances the turn, **When** the round increments from 3 to 4, **Then** the round badge updates without layout shift. + +#### Story TB2 — Fixed Top Bar (Priority: P1) + +As a game master managing a large encounter with many combatants, I want the turn navigation bar pinned to the top of the screen so that I can always navigate turns without scrolling away from the controls. + +**Acceptance Scenarios**: + +1. **Given** an encounter with enough combatants to overflow the viewport, **When** the user scrolls through the combatant list, **Then** the turn navigation bar (Previous / round badge / combatant name / Next) remains fixed at the top of the encounter area and never scrolls out of view. +2. **Given** any viewport width, **When** the encounter tracker is displayed, **Then** the top navigation bar remains fixed and the combatant list scrolls independently. + +#### Story TB3 — Left-Center-Right Layout (Priority: P1) + +As a game master, I want the top bar to follow a clear left-center-right structure so that controls are always in predictable positions regardless of combatant name length. + +**Acceptance Scenarios**: + +1. **Given** an encounter with a short combatant name like "Orc", **When** viewing the bar, **Then** the layout maintains the left-center-right structure with the name centered. +2. **Given** an encounter with a long combatant name like "Ancient Red Dragon Wyrm of the Northern Wastes", **When** viewing the bar, **Then** the name truncates gracefully without pushing action buttons off-screen. +3. **Given** a narrow viewport, **When** viewing the bar, **Then** all three zones (round badge, combatant name, action buttons) remain visible and accessible. + +#### Story TB4 — Turn Navigation Controls Accessible and Correctly Disabled (Priority: P1) + +As a game master, I want the Previous Turn and Next Turn buttons placed prominently in the fixed top bar, with the Previous button disabled when no retreat is possible, so that I can quickly navigate turns from any scroll position. + +**Acceptance Scenarios**: + +1. **Given** the encounter tracker is displayed, **When** the user looks at the screen, **Then** the Previous Turn and Next Turn buttons are visible in the fixed top bar, above the combatant list. +2. **Given** the encounter is at round 1 with the first combatant active, **When** the user views the turn controls, **Then** the Previous Turn button is disabled (visually indicating it cannot be used). +3. **Given** the encounter has no combatants, **When** the user views the tracker, **Then** both turn navigation buttons are disabled. +4. **Given** the tracker has many combatants requiring scrolling, **When** the user scrolls down, **Then** the turn navigation controls remain accessible at the top (no scrolling needed to reach them). +5. **Given** the Previous and Next buttons are displayed, **When** the user looks at the controls, **Then** the buttons are visually distinct with clear directional indicators (icons, labels, or both). + +#### Story TB5 — No Combatants State (Priority: P2) + +As a game master with an empty encounter, I want the top bar to handle the no-combatants state gracefully so that it does not appear broken. + +**Acceptance Scenarios**: + +1. **Given** an encounter with no combatants, **When** viewing the top bar, **Then** the round badge still shows the round number and the center area displays a placeholder message indicating no active combatant. + +#### Story TB6 — Active Combatant Scrolled into View on Turn Change (Priority: P2) + +As a game master, I want the active combatant's row to automatically scroll into view when the turn changes so that the active row is always visible after navigation. + +**Acceptance Scenarios**: + +1. **Given** the active combatant's row is scrolled off-screen, **When** the turn changes via Next or Previous, **Then** the combatant list automatically scrolls to bring the newly active combatant's row into view. + +--- + +## Requirements *(mandatory)* + +### Functional Requirements + +**Advancing Turns** + +- **FR-001**: The domain MUST expose an AdvanceTurn operation that accepts an Encounter and returns the resulting Encounter state plus emitted domain events. +- **FR-002**: AdvanceTurn MUST increment `activeIndex` by 1, wrapping to 0 when advancing past the last combatant. +- **FR-003**: When `activeIndex` wraps to 0, `roundNumber` MUST increment by 1. +- **FR-004**: AdvanceTurn on an empty encounter MUST return a DomainError without modifying state or emitting events. +- **FR-005**: Domain events MUST be returned as values from AdvanceTurn, not dispatched via side effects. + +**Retreating Turns** + +- **FR-006**: The domain MUST expose a RetreatTurn operation that moves the active turn to the previous combatant in initiative order. +- **FR-007**: RetreatTurn MUST decrement `activeIndex` by 1. When `activeIndex` would go below 0, it MUST wrap to the last combatant and decrement `roundNumber` by 1. +- **FR-008**: RetreatTurn at round 1 with `activeIndex` 0 MUST fail with a DomainError. This is the earliest possible encounter state. +- **FR-009**: RetreatTurn on an empty encounter MUST fail with a DomainError without modifying state or emitting events. +- **FR-010**: RetreatTurn MUST emit a TurnRetreated event on success. When crossing a round boundary, a RoundRetreated event MUST also be emitted: TurnRetreated first, then RoundRetreated. +- **FR-011**: RetreatTurn MUST be a pure function of the current encounter state. Given identical input, output MUST be identical. + +**Turn Order (Initiative Sorting)** + +- **FR-012**: The system MUST automatically reorder combatants from highest to lowest initiative whenever an initiative value is set, changed, or cleared. +- **FR-013**: Combatants without an initiative value MUST be placed after all combatants that have initiative values. +- **FR-014**: The sort MUST be stable: combatants with equal initiative (or multiple combatants without initiative) retain their relative order. +- **FR-015**: The system MUST preserve the active combatant's turn identity when reordering occurs — the active turn tracks the combatant by identity, not by `activeIndex` position. +- **FR-016**: Zero and negative integers MUST be accepted as valid initiative values. +- **FR-017**: Non-integer initiative values MUST be rejected with an error. +- **FR-018**: The system MUST emit an InitiativeSet domain event when a combatant's initiative is set or changed. + +**Top Bar Display** + +- **FR-019**: The top bar MUST remain fixed at the top of the encounter tracker area and MUST NOT scroll out of view. +- **FR-020**: The top bar MUST follow a left-center-right layout: [prev button] [round badge] — [combatant name] — [action buttons] [next button]. +- **FR-021**: The round number MUST be displayed as a compact, visually muted badge or pill element (format: "R{n}", e.g., "R1", "R2") positioned to the left of the combatant name. +- **FR-022**: The current combatant's name MUST be displayed as a prominent, centered label and MUST be the visual focal point of the bar. +- **FR-023**: The round number and combatant name MUST be visually distinct elements — not joined by a dash or rendered as a single string. +- **FR-024**: The combatant name MUST truncate with an ellipsis when it exceeds available space rather than causing layout overflow. +- **FR-025**: When no combatants exist, the center area MUST display a placeholder message; the round badge MUST still show the current round number. +- **FR-026**: The Previous Turn button MUST be disabled when the encounter is at its earliest state (round 1, first combatant active). +- **FR-027**: Both turn navigation buttons MUST be disabled when the encounter has no combatants. +- **FR-028**: The turn navigation buttons MUST have clear directional indicators (icons, labels, or both) so the user can distinguish Previous from Next at a glance. +- **FR-029**: When the active turn changes via Next or Previous, the active combatant's row MUST automatically scroll into view if it is not currently visible in the scrollable list area. +- **FR-030**: The combatant list MUST be the only scrollable region — positioned between the fixed top bar and the fixed bottom bar. + +--- + +## Success Criteria *(mandatory)* + +- **SC-001**: All AdvanceTurn acceptance scenarios pass as deterministic, pure-function tests with no I/O dependencies. +- **SC-002**: Invariants INV-1 through INV-7 are verified by tests. +- **SC-003**: The domain module has zero imports from application, adapter, or UI layers (layer boundary compliance). +- **SC-004**: A user can reverse a turn advancement using a single click on the Previous Turn button. +- **SC-005**: The Previous Turn button is correctly disabled at the earliest encounter state (round 1, first combatant), preventing invalid operations 100% of the time. +- **SC-006**: All RetreatTurn acceptance scenarios pass as deterministic, pure-function tests with no I/O dependencies. +- **SC-007**: After setting or changing any initiative value, the encounter's combatant order immediately reflects the correct descending initiative sort. +- **SC-008**: The active combatant's turn is never lost or shifted to a different combatant due to an initiative-driven reorder. +- **SC-009**: Combatants without initiative are always displayed after combatants with initiative values. +- **SC-010**: Users can identify the current round number and active combatant in under 1 second of looking at the top bar, without needing to parse a combined string. +- **SC-011**: The top bar layout remains visually balanced and functional across viewport widths from 320px to 1920px. +- **SC-012**: All existing top bar functionality (turn navigation, roll initiative, manage sources, clear encounter) remains fully operational. +- **SC-013**: Combatant names up to 40 characters display without layout breakage; longer names truncate gracefully. +- **SC-014**: With 20+ combatants in an encounter, the turn navigation bar remains visible at all scroll positions without any user action beyond normal scrolling. + +--- + +## Edge Cases + +- **Empty combatant list**: Valid aggregate state. AdvanceTurn and RetreatTurn both return a DomainError (no state change, no events). The top bar shows the round badge and a placeholder for the combatant name. +- **Single combatant, advancing**: Every advance wraps and increments the round. Both TurnAdvanced and RoundAdvanced are emitted. +- **Single combatant, retreating at round 1**: RetreatTurn fails because there is no previous turn. +- **Single combatant, retreating at round > 1**: RetreatTurn succeeds, decrementing the round; both TurnRetreated and RoundRetreated are emitted. +- **Large round numbers**: No overflow or special-case behavior; round increments and decrements uniformly. +- **Retreating at round 1, activeIndex 0**: The earliest possible state — RetreatTurn MUST fail. Round number can never drop below 1. +- **All combatants have the same initiative**: Relative order is preserved (stable sort preserves insertion order). +- **Initiative cleared mid-encounter**: The combatant moves to the end of the turn order. The active combatant identity is preserved. +- **Initiative changed for the active combatant**: Reorder occurs; the active turn still points to that combatant at its new position. +- **Initiative set to zero or a negative value**: Treated as a normal integer — sorted accordingly. +- **Combatant name extremely long (50+ characters)**: Name truncates with an ellipsis; layout does not break. +- **Very narrow viewport**: Round badge and navigation buttons remain visible; combatant name truncates. +- **Very short viewport (e.g., 400px tall)**: Combatant list area is still scrollable, even if only a small portion is visible. +- **Active combatant scrolled off-screen**: On turn change, the list auto-scrolls to bring the newly active combatant into view. + +--- + +## Assumptions + +- Initiative values are integers (no decimals). There is no dice-rolling or randomization in the domain — the user provides the final value. +- Tiebreaking for equal initiative values uses stable sort (preserves existing relative order). Secondary tiebreakers (e.g., Dexterity modifier) are not included in the MVP baseline. +- RetreatTurn is the inverse of AdvanceTurn for position and round tracking only. It does not restore any other state (e.g., HP changes made during a combatant's turn are not undone). It is not a full undo/redo stack. +- Keyboard shortcuts for Previous/Next Turn navigation are not included in the MVP baseline. +- The round badge uses the compact format "R{number}" (e.g., "R1", "R2"). +- No new domain entities or persistence changes are required for the top bar display — it is a presentational layer over existing encounter state. diff --git a/specs/003-combatant-state/spec.md b/specs/003-combatant-state/spec.md new file mode 100644 index 0000000..02ca89e --- /dev/null +++ b/specs/003-combatant-state/spec.md @@ -0,0 +1,492 @@ +# Feature Specification: Combatant State + +**Feature Branch**: `003-combatant-state` +**Created**: 2026-03-03 +**Status**: Implemented + +--- + +## Overview + +Combatant State covers all per-combatant data tracked during an encounter: hit points, armor class, conditions, concentration, and initiative. + +**Structure**: This spec is organized by topic area. Each topic section contains its own user scenarios, requirements, and edge cases. The Combatant Row Layout section covers cross-cutting UI concerns. + +## Domain Model Reference + +```ts +interface Combatant { + readonly id: CombatantId; // branded string + readonly name: string; + readonly initiative?: number; // integer, undefined = unset + readonly maxHp?: number; // positive integer + readonly currentHp?: number; // 0..maxHp + readonly ac?: number; // non-negative integer + readonly conditions?: readonly ConditionId[]; + readonly isConcentrating?: boolean; + readonly creatureId?: CreatureId; // link to bestiary entry +} +``` + +--- + +## Hit Points + +### User Stories + +**Story HP-1 — Set Max HP (P1)** +As a game master, I want to assign a maximum HP value to a combatant so that I can track their health during the encounter. + +Acceptance scenarios: +1. **Given** a combatant exists, **When** the user sets max HP to a positive integer, **Then** the combatant's max HP is stored and current HP defaults to that value. +2. **Given** a combatant has max HP 20 and current HP 20, **When** the user lowers max HP to 15, **Then** current HP is clamped to 15. +3. **Given** a combatant has max HP 20 and current HP 20 (full health), **When** the user increases max HP to 30, **Then** current HP increases to 30 (stays at full). +4. **Given** a combatant has max HP 20 and current HP 12 (not full), **When** the user increases max HP to 30, **Then** current HP remains at 12. +5. **Given** the max HP inline edit is active, **When** the user clears the field and confirms, **Then** max HP is unset and HP tracking is removed entirely. + +**Story HP-2 — Apply HP Delta (P1)** +As a game master in the heat of combat, I want to type a damage or healing number and immediately apply it to a combatant's HP so that I can keep up with fast-paced encounters without mental arithmetic. + +Acceptance scenarios: +1. **Given** a combatant has 20/20 HP, **When** the user types 7 into the delta input and presses Enter, **Then** current HP decreases to 13. +2. **Given** a combatant has 10/20 HP, **When** the user types 15 and presses Enter, **Then** current HP is clamped to 0. +3. **Given** a combatant has 10/20 HP and types 5 then clicks the heal button, **Then** current HP increases to 15. +4. **Given** a combatant has 18/20 HP and types 10 then clicks the heal button, **Then** current HP is clamped to 20. +5. **Given** any confirmed delta, **Then** the input field clears automatically and is ready for the next entry. +6. **Given** the user types 0 and presses Enter, **Then** the input is rejected and HP remains unchanged. +7. **Given** the delta input is focused and the user presses Escape, **Then** the input clears without applying any change. + +**Story HP-3 — Click-to-Adjust Popover (P1)** +As a DM running combat, I want to click on a combatant's current HP value to open a small adjustment popover so that the combatant row is visually clean and I can still quickly apply damage or healing when needed. + +Acceptance scenarios: +1. **Given** a combatant with max HP set, **When** viewing the row at rest, **Then** only the current HP number and max HP are visible — no delta input or action buttons. +2. **Given** a combatant with max HP set, **When** the user clicks the current HP number, **Then** a small popover opens with an auto-focused numeric input and Damage/Heal buttons. +3. **Given** the HP popover is open with a valid number, **When** the user presses Enter, **Then** damage is applied and the popover closes. +4. **Given** the HP popover is open with a valid number, **When** the user presses Shift+Enter, **Then** healing is applied and the popover closes. +5. **Given** the HP popover is open, **When** the user presses Escape, **Then** the popover closes without changes. +6. **Given** the HP popover is open, **When** the user clicks outside, **Then** the popover closes without changes. +7. **Given** a combatant with no max HP set, **When** viewing the row, **Then** the HP area shows only the max HP clickable placeholder — no current HP value. + +**Story HP-4 — Direct HP Entry (P2)** +As a game master, I want to type a specific absolute current HP value directly so I can apply large corrections in one action. + +Acceptance scenarios: +1. **Given** a combatant has max HP 50, **When** the user types 35 into the current HP field, **Then** current HP is set to 35. +2. **Given** a combatant has max HP 50, **When** the user types 60, **Then** current HP is clamped to 50. +3. **Given** a combatant has max HP 50, **When** the user types -5, **Then** current HP is clamped to 0. + +**Story HP-5 — HP Status Indicators (P1)** +As a game master, I want to see at a glance which combatants are bloodied or unconscious so I can narrate the battle and make tactical decisions without mental math. + +Acceptance scenarios: +1. **Given** a combatant has max HP 20 and current HP 10 (at half), **Then** no bloodied indicator is shown (10 is not less than 20/2 = 10). +2. **Given** a combatant has max HP 20 and current HP 9 (below half), **Then** the bloodied indicator is visible (amber color treatment on HP value). +3. **Given** a combatant has max HP 21 and current HP 10 (below 10.5), **Then** the bloodied indicator is shown. +4. **Given** a combatant has max HP 20 and current HP 0, **Then** the unconscious/dead indicator is shown (red color; row visually muted). +5. **Given** a combatant at 0 HP is healed above 0, **Then** the unconscious indicator is removed and the correct status is applied. +6. **Given** a combatant has no max HP set, **Then** no status indicator is shown. +7. **Given** a combatant at full HP 20/20, **When** 11 damage is dealt (-> 9/20), **Then** the indicator transitions to bloodied. +8. **Given** a bloodied combatant 5/20, **When** 5 damage is dealt (-> 0/20), **Then** the indicator transitions to unconscious/dead. +9. **Given** an unconscious combatant 0/20, **When** 15 HP is healed (-> 15/20), **Then** the indicator transitions directly to healthy (skips bloodied since 15 > 10). + +**Story HP-6 — HP Persists Across Reloads (P2)** +As a game master, I want HP values to survive page reloads so that I do not lose health tracking mid-session. + +Acceptance scenarios: +1. **Given** a combatant has max HP 30 and current HP 18, **When** the page is reloaded, **Then** both values are restored exactly. + +### Requirements + +- **FR-001**: Each combatant MAY have an optional `maxHp` value (positive integer >= 1). HP tracking is optional per combatant. +- **FR-002**: When `maxHp` is first set, `currentHp` MUST default to `maxHp`. +- **FR-003**: `currentHp` MUST be clamped to [0, `maxHp`] at all times. +- **FR-004**: The system MUST provide an inline HP delta input per combatant (hidden behind a click-to-open popover on the current HP value). +- **FR-005**: The HP popover MUST contain a single auto-focused numeric input and Damage and Heal action buttons. +- **FR-006**: Pressing Enter in the popover MUST apply the entered value as damage; Shift+Enter MUST apply it as healing; Escape MUST dismiss without change; clicking outside MUST dismiss without change. +- **FR-007**: When a damage value is confirmed, the system MUST subtract the entered amount from `currentHp`, clamping to 0. +- **FR-008**: When a healing value is confirmed, the system MUST add the entered amount to `currentHp`, clamping to `maxHp`. +- **FR-009**: After any delta is applied, the input MUST clear automatically. +- **FR-010**: The delta input MUST only accept positive integers. Zero, negative, and non-numeric values MUST be rejected. +- **FR-011**: Direct editing of the absolute `currentHp` value MUST remain available alongside the delta input. +- **FR-012**: `maxHp` MUST display as compact static text with click-to-edit. The value is committed on Enter or blur; Escape cancels. Intermediate editing (clearing the field to retype) MUST NOT affect `currentHp` until committed. +- **FR-013**: When `maxHp` is reduced below `currentHp`, `currentHp` MUST be clamped to the new `maxHp`. +- **FR-014**: When `maxHp` increases and the combatant was at full health, `currentHp` MUST increase to match the new `maxHp`. +- **FR-015**: When `maxHp` increases and the combatant was NOT at full health, `currentHp` MUST remain unchanged (unless clamped by FR-013). +- **FR-016**: `maxHp` MUST reject zero, negative, and non-integer values. +- **FR-017**: HP values MUST persist across page reloads via the existing persistence mechanism. +- **FR-018**: The HP status MUST be derived as a pure domain computation: `healthy` (currentHp >= maxHp / 2), `bloodied` (0 < currentHp < maxHp / 2), `unconscious` (currentHp <= 0). The status is not stored — computed on demand. +- **FR-019**: The HP area MUST display the bloodied color treatment (amber) on the current HP value when status is `bloodied`. +- **FR-020**: The HP area MUST display the unconscious color treatment (red) and the combatant row MUST appear visually muted when status is `unconscious`. +- **FR-021**: Status indicators MUST NOT be shown when `maxHp` is not set. +- **FR-022**: Visual status indicators MUST update within the same interaction frame as the HP change — no perceptible delay. + +### Edge Cases + +- `maxHp` of 1: at 1/1 the combatant is healthy; at 0/1 unconscious. No bloodied state is possible. +- `maxHp` of 2: at 1/2 the combatant is healthy (1 is not strictly less than 1); at 0/2 unconscious. +- When `maxHp` is cleared, `currentHp` is also cleared; the combatant returns to the no-HP state. +- Entering a non-numeric value in any HP field is rejected; the previous value is preserved. +- Entering a very large number (e.g., 99999) is applied normally; clamping prevents invalid state. +- Submitting an empty delta input applies no change; the input remains ready. +- When the user rapidly applies multiple deltas, each is applied sequentially; none are lost. +- HP tracking is entirely absent for combatants with no `maxHp` set — no HP controls are shown. +- There is no temporary HP in the MVP baseline. +- There is no death/unconscious game mechanic triggered at 0 HP; the system displays the state only. +- There is no damage type tracking, resistance/vulnerability calculation, or hit log in the MVP baseline. +- There is no undo/redo for HP changes in the MVP baseline. + +--- + +## Armor Class + +### User Stories + +**Story AC-1 — Set and Display AC (P1)** +As a game master, I want to assign an Armor Class value to a combatant and see it displayed as a shield shape so I can reference it at a glance during combat. + +Acceptance scenarios: +1. **Given** a combatant exists, **When** the user clicks the AC shield area and enters 17, **Then** the combatant's AC is stored and the shield shape displays "17". +2. **Given** a combatant with AC 15, **When** viewing the row, **Then** the AC number is displayed inside a shield-shaped outline (not a separate icon + number). +3. **Given** a combatant with no AC set, **When** viewing the row, **Then** the shield shape is shown in an empty/placeholder state. +4. **Given** multiple combatants with different AC values, **When** viewing the encounter list, **Then** each displays its own correct AC. + +**Story AC-2 — Edit AC (P2)** +As a game master, I want to edit an existing combatant's AC inline so I can correct or update it without navigating away. + +Acceptance scenarios: +1. **Given** a combatant with AC 15, **When** the user clicks the shield, **Then** an inline input appears pre-filled with 15 and selected. +2. **Given** the inline AC edit is active, **When** the user types 18 and presses Enter, **Then** AC updates to 18 and the display returns to static mode. +3. **Given** the inline AC edit is active, **When** the user blurs, **Then** AC is committed and the display returns to static mode. +4. **Given** the inline AC edit is active, **When** the user presses Escape, **Then** the edit is cancelled and the original value is preserved. +5. **Given** the inline AC edit is active, **When** the user clears the field and presses Enter, **Then** AC is unset and the shield shows an empty state. + +### Requirements + +- **FR-023**: Each combatant MAY have an optional `ac` value, a non-negative integer (>= 0). +- **FR-024**: AC MUST be displayed inside a shield-shaped visual element. The separate shield icon is replaced by the shield shape itself. +- **FR-025**: The shield shape MUST be shown in all cases (set, unset/empty state). No AC value means an empty-state shield, not hidden. +- **FR-026**: Clicking the shield MUST open an inline edit input with the current value pre-filled and selected. +- **FR-027**: The inline AC edit MUST commit on Enter or blur and cancel on Escape. +- **FR-028**: Clearing the AC field and confirming MUST unset AC. +- **FR-029**: AC MUST reject negative values. Zero is a valid AC. +- **FR-030**: AC values MUST persist via the existing persistence mechanism. +- **FR-031**: The AC shield MUST scale appropriately for single-digit, double-digit, and any valid AC values. + +### Edge Cases + +- AC 0 is valid and MUST be displayed. +- Negative AC is not accepted; the input is rejected. +- MVP baseline does not include AC-based calculations (to-hit comparisons, conditional formatting based on AC thresholds). + +--- + +## Conditions & Concentration + +### User Stories + +**Story CC-1 — Add a Condition (P1)** +As a DM running an encounter, I want to quickly apply a condition to a combatant so I can track status effects during combat. + +Acceptance scenarios: +1. **Given** a combatant row is not hovered and has no conditions, **Then** no condition UI is visible. +2. **Given** a combatant row is hovered, **When** no conditions are active, **Then** a "+" button appears inline after the creature name. +3. **Given** the "+" button is visible, **When** the user clicks it, **Then** a compact condition picker opens showing all 15 conditions as icon + label pairs. +4. **Given** the picker is open, **When** the user clicks a condition, **Then** it is toggled on and its icon appears inline after the creature name. +5. **Given** the picker is open with active conditions already marked, **When** viewing the picker, **Then** active conditions are visually distinguished from inactive ones. + +**Story CC-2 — Remove a Condition (P1)** +As a DM, I want to remove a condition from a combatant when the effect ends so the tracker stays accurate. + +Acceptance scenarios: +1. **Given** a combatant has active conditions, **When** the user clicks an active condition icon tag inline, **Then** the condition is removed and the icon disappears. +2. **Given** the condition picker is open, **When** the user clicks an active condition, **Then** it is toggled off and removed from the row. +3. **Given** a combatant with one condition and it is removed, **Then** only the hover-revealed "+" button remains. + +**Story CC-3 — View Condition Name via Tooltip (P2)** +As a DM, I want to hover over a condition icon to see its name so I can identify conditions without memorizing icons. + +Acceptance scenarios: +1. **Given** a combatant has an active condition, **When** the user hovers over its icon, **Then** a tooltip shows the condition name (e.g., "Blinded"). +2. **Given** the user moves the cursor away from the icon, **Then** the tooltip disappears. + +**Story CC-4 — Multiple Conditions (P2)** +As a DM, I want to apply multiple conditions to a single combatant so I can track complex combat situations. + +Acceptance scenarios: +1. **Given** a combatant with one condition, **When** another is added, **Then** both icons appear inline. +2. **Given** a combatant with many conditions, **When** viewing the row, **Then** icons wrap within the name column without increasing row width; row height may increase. +3. **Given** "poisoned" was applied first and "blinded" second, **When** viewing the row, **Then** "blinded" appears before "poisoned" (fixed definition order, not insertion order). + +**Story CC-5 — Toggle Concentration (P1)** +As a DM, I want to mark a combatant as concentrating on a spell by clicking a Brain icon in the row gutter so I can track spells requiring concentration. + +Acceptance scenarios: +1. **Given** a combatant row is not hovered and concentration is inactive, **Then** the Brain icon is hidden. +2. **Given** a combatant row is hovered and concentration is inactive, **Then** the Brain icon appears in a muted/faded style. +3. **Given** the Brain icon is visible, **When** the user clicks it, **Then** concentration activates and the icon remains visible with an active style. +4. **Given** concentration is active and the row is not hovered, **Then** the Brain icon remains visible. +5. **Given** concentration is active, **When** the user clicks the Brain icon again, **Then** concentration deactivates and the icon hides (unless the row is still hovered). +6. **Given** the Brain icon is visible, **When** the user hovers over it, **Then** a tooltip reading "Concentrating" appears. + +**Story CC-6 — Visual Feedback for Concentration (P2)** +As a DM, I want concentrating combatants to have a visible row accent so I can identify them at a glance without interacting. + +Acceptance scenarios: +1. **Given** concentration is active, **When** viewing the encounter tracker, **Then** the combatant row shows a colored left border accent (`border-l-purple-400`). +2. **Given** concentration is inactive, **Then** no concentration accent is shown. +3. **Given** concentration is toggled off, **Then** the left border accent disappears immediately. + +**Story CC-7 — Damage Pulse Alert (P3)** +As a DM, I want a visual alert when a concentrating combatant takes damage so I remember to call for a concentration check. + +Acceptance scenarios: +1. **Given** a combatant is concentrating, **When** the combatant takes damage (HP reduced), **Then** the Brain icon and row accent briefly pulse/flash for 700 ms. +2. **Given** a combatant is concentrating, **When** the combatant is healed, **Then** no pulse/flash occurs. +3. **Given** a combatant is NOT concentrating, **When** damage is taken, **Then** no pulse/flash occurs. +4. **Given** a concentrating combatant takes damage, **When** the animation completes, **Then** the row returns to its normal concentration-active appearance. + +### Requirements + +- **FR-032**: The MVP MUST support the following 15 standard D&D 5e conditions: blinded, charmed, deafened, exhaustion, frightened, grappled, incapacitated, invisible, paralyzed, petrified, poisoned, prone, restrained, stunned, unconscious. +- **FR-033**: Each condition MUST have a fixed icon and color mapping (Lucide icons; no emoji): + + | Condition | Icon | Color | + |---------------|------------|---------| + | Blinded | EyeOff | neutral | + | Charmed | Heart | pink | + | Deafened | EarOff | neutral | + | Exhaustion | BatteryLow | amber | + | Frightened | Siren | orange | + | Grappled | Hand | neutral | + | Incapacitated | Ban | gray | + | Invisible | Ghost | violet | + | Paralyzed | ZapOff | yellow | + | Petrified | Gem | slate | + | Poisoned | Droplet | green | + | Prone | ArrowDown | neutral | + | Restrained | Link | neutral | + | Stunned | Sparkles | yellow | + | Unconscious | Moon | indigo | + +- **FR-034**: Active condition icons MUST appear inline after the creature name within the same row, not on a separate line. +- **FR-035**: Conditions MUST be displayed in the fixed definition order (blinded -> unconscious), regardless of application order. +- **FR-036**: The "+" condition button MUST be hidden by default and appear only on row hover (or touch/focus on touch devices). +- **FR-037**: Clicking "+" MUST open the compact condition picker showing all conditions as icon + label pairs. +- **FR-038**: Clicking a condition in the picker MUST toggle it on or off. +- **FR-039**: Clicking an active condition icon tag in the row MUST remove that condition. +- **FR-040**: Hovering an active condition icon MUST show a tooltip with the condition name. +- **FR-041**: Condition icons MUST NOT increase the row's width; row height MAY increase to accommodate wrapping. +- **FR-042**: The condition picker MUST close when the user clicks outside of it. +- **FR-043**: Conditions MUST persist as part of combatant state (surviving page reload). +- **FR-044**: The condition data model MUST be extensible for future additions (e.g., mechanical effects, descriptions). +- **FR-045**: `isConcentrating` MUST be stored as an optional boolean on the combatant, separate from the `conditions` array. +- **FR-046**: Concentration MUST NOT appear in or interact with the condition tag system. +- **FR-047**: The Brain icon toggle MUST be hidden at row rest and revealed on hover (same hover pattern as the "+" button). +- **FR-048**: The Brain icon MUST remain visible whenever concentration is active, regardless of hover state. +- **FR-049**: A tooltip reading "Concentrating" MUST appear when hovering the Brain icon. +- **FR-050**: The active Brain icon MUST use `text-purple-400`; the inactive (hover-revealed) Brain icon MUST use a muted style (`text-muted-foreground opacity-50`). +- **FR-051**: The concentration left border accent MUST use `border-l-purple-400`. +- **FR-052**: The concentration toggle's clickable area MUST extend to fill the full gutter between the left border and the initiative column. +- **FR-053**: When a concentrating combatant takes damage, the Brain icon and row accent MUST briefly pulse/flash for 700 ms. +- **FR-054**: The pulse animation MUST NOT trigger on healing or when concentration is inactive. +- **FR-055**: Concentration MUST persist across page reloads via existing storage. + +### Edge Cases + +- When all 15 conditions are applied, icons wrap within the row; row height increases but width does not. +- When a combatant is removed, all its conditions and concentration state are discarded. +- When the picker is open and the user clicks outside, it closes. +- When a condition is toggled on then immediately off in the picker, it does not appear in the row. +- When concentration is toggled during an active pulse animation, the animation cancels and the new state applies immediately. +- Multiple combatants may concentrate simultaneously; concentration is independent per combatant. +- Conditions have no mechanical effects in the MVP baseline (no auto-disadvantage, no automation). + +--- + +## Initiative + +### User Stories + +**Story INI-1 — Set Initiative Value (P1)** +As a game master running an encounter, I want to assign an initiative value to a combatant so that the encounter's turn order reflects each combatant's rolled initiative. + +Acceptance scenarios: +1. **Given** a combatant with no initiative set, **When** the user sets initiative to 15, **Then** the combatant has initiative value 15. +2. **Given** a combatant with initiative 15, **When** the user changes initiative to 8, **Then** the combatant has initiative value 8. +3. **Given** a combatant with no initiative set, **When** the user attempts to set a non-integer value, **Then** the system rejects the input and initiative remains unset. +4. **Given** a combatant with initiative 15, **When** the user clears initiative, **Then** the initiative is unset and the combatant moves to the end of the turn order. +5. **Given** a combatant has an initiative value displayed as plain text, **When** the user clicks it, **Then** an inline editor opens to change or clear it. + +**Story INI-2 — Roll Initiative for a Single Combatant (P1)** +As a DM, I want to click a d20 icon next to a combatant's initiative slot to randomly roll initiative (1d20 + initiative modifier) so the result is immediately placed into the initiative field and the tracker re-sorts. + +Acceptance scenarios: +1. **Given** a combatant linked to a bestiary creature (e.g., Aboleth, initiative modifier +7) with no initiative, **When** the user clicks the d20 icon, **Then** a random value in the range [8, 27] is stored as initiative and the list re-sorts descending. +2. **Given** a combatant NOT linked to a bestiary creature, **When** viewing the row, **Then** the initiative slot shows "--" (clickable to type a value manually) — no d20 button. +3. **Given** a combatant whose initiative modifier is negative (e.g., -2), **When** the d20 button is clicked, **Then** the result ranges from -1 to 18. +4. **Given** a combatant already has an initiative value, **Then** the d20 button is replaced by the value as plain text; clicking it opens the inline editor. + +**Story INI-3 — Roll Initiative for All Eligible Combatants (P2)** +As a DM, I want a "Roll All Initiative" button in the top bar to roll for all bestiary combatants at once so I can set up the initiative order quickly at the start of combat. + +Acceptance scenarios: +1. **Given** 3 bestiary combatants (no initiative) and 1 manual combatant, **When** the roll-all button is clicked, **Then** all 3 bestiary combatants receive rolled initiative values; the manual combatant is unchanged. +2. **Given** bestiary combatants that already have initiative values, **When** the roll-all button is clicked, **Then** those combatants are skipped; only bestiary combatants without initiative are rolled. +3. **Given** no bestiary combatants, **When** the roll-all button is clicked, **Then** no changes occur. + +**Story INI-4 — Display Initiative Modifier in Stat Block (P1)** +As a DM viewing a creature's stat block, I want to see the creature's initiative modifier and passive initiative so I can reference it when rolling. + +Acceptance scenarios: +1. **Given** a creature with DEX 9, CR 10, initiative proficiency multiplier 2 (e.g., Aboleth), **When** the stat block is displayed, **Then** the initiative line shows "Initiative +7 (17)" (-1 + 2x4 = +7; passive = 17). +2. **Given** a creature with proficiency multiplier 1, **Then** the initiative modifier includes 1x the proficiency bonus. +3. **Given** a creature with no `initiative` field in bestiary data, **Then** only the DEX modifier is shown (e.g., DEX 14 -> "Initiative +2 (12)"). +4. **Given** a creature with a negative initiative modifier (e.g., DEX 8, no proficiency), **Then** the line uses a minus sign (e.g., "Initiative -1 (9)"). +5. **Given** a combatant without bestiary data, **Then** no initiative line is shown in the stat block. + +**Story INI-5 — Combatants Without Initiative (P2)** +As a game master, I want combatants without initiative set to appear at the end of the turn order so the encounter remains usable while I am still entering values. + +Acceptance scenarios: +1. **Given** combatants A (initiative 15), B (no initiative), C (initiative 10), **Then** order is A, C, B. +2. **Given** A (no initiative) and B (no initiative), **Then** their relative order is preserved from when they were added. +3. **Given** combatant A (no initiative), **When** initiative is set to 12, **Then** A moves to its correct sorted position. + +### Requirements + +- **FR-056**: System MUST allow setting an integer initiative value for any combatant. +- **FR-057**: System MUST allow changing an existing initiative value. +- **FR-058**: System MUST allow clearing (unsetting) a combatant's initiative value. +- **FR-059**: System MUST reject non-integer initiative values and return a domain error. +- **FR-060**: System MUST accept zero and negative integers as valid initiative values. +- **FR-061**: System MUST automatically reorder combatants highest-to-lowest initiative whenever a value is set, changed, or cleared. +- **FR-062**: Combatants without initiative MUST be placed after all combatants with initiative values. +- **FR-063**: System MUST use a stable sort so combatants with equal initiative (or multiple without initiative) retain their relative order. +- **FR-064**: System MUST preserve the active combatant's turn through reorders — the active turn tracks combatant identity, not list position. +- **FR-065**: System MUST emit a domain event when a combatant's initiative is set or changed. +- **FR-066**: System MUST display a d20 icon button in the initiative slot for every combatant that has a linked bestiary creature and does not yet have an initiative value. +- **FR-067**: System MUST NOT display the d20 button for combatants without a linked bestiary creature; they see a "--" placeholder that is clickable to type a value. +- **FR-068**: When the d20 button is clicked, the system MUST generate a uniform random integer in [1, 20], add the creature's initiative modifier, and set the result as the initiative value. +- **FR-069**: The initiative modifier MUST be calculated as: DEX modifier + (proficiency multiplier x proficiency bonus), where DEX modifier = floor((DEX - 10) / 2) and proficiency bonus is derived from challenge rating. +- **FR-070**: The initiative proficiency multiplier MUST be read from `initiative.proficiency` in bestiary data (0 if absent, 1 for proficiency, 2 for expertise). +- **FR-071**: System MUST provide a "Roll All Initiative" button in the top bar. Clicking it MUST roll for every bestiary-linked combatant that does not already have an initiative value; all others MUST be left unchanged. +- **FR-072**: After any initiative roll (single or batch), the encounter list MUST re-sort descending per existing behavior. +- **FR-073**: Once a combatant has an initiative value, the d20 button is replaced by the value as plain text using a click-to-edit pattern (consistent with AC and name editing). +- **FR-074**: The stat block header MUST display initiative in the format "Initiative +X (Y)" where X is the modifier (with + or - sign) and Y = 10 + X. +- **FR-075**: The initiative display in the stat block MUST be positioned adjacent to the AC value, matching Monster Manual 2024 layout. +- **FR-076**: No initiative line MUST be shown in the stat block for combatants without bestiary data. +- **FR-077**: The initiative display in the stat block is display-only. It MUST NOT modify encounter turn order or trigger rolling automatically. +- **FR-078**: The d20 roll-initiative icon MUST be displayed larger than 20x20 px while remaining contained within the initiative column. +- **FR-079**: The "Roll All Initiative" d20 in the top bar MUST be sized at 24 px; the clear-encounter (trash) icon MUST be sized at 20 px. Both are visually grouped together, separated from turn navigation controls by spacing. + +### Edge Cases + +- Setting initiative to zero is valid and treated normally in sorting. +- Negative initiative values are valid (some game systems use them). +- When a combatant is added without initiative during an ongoing encounter, it appears at the end of the order. +- When all combatants have the same initiative value, their insertion order is preserved. +- When the active combatant's own initiative changes causing a reorder, the active turn still points to that combatant. +- When another combatant's initiative changes causing a reorder, the active turn still points to the current active combatant. +- When a combatant's initiative modifier produces a roll result of 0 or negative, the value is stored as-is. +- When multiple combatants roll the same initiative, ties are resolved by preserving relative insertion order. +- A minus sign is used for negative modifiers in the stat block display, not a hyphen. +- When a creature has DEX producing a modifier of exactly 0 and no initiative proficiency, display "Initiative +0 (10)". +- For manually-added combatants: no initiative modifier is available, so no d20 button and no stat block initiative line. +- The passive initiative value shown in the stat block is reference-only; only the active modifier (+X) is used for rolling. +- Random number generation for dice rolls uses standard browser randomness; cryptographic randomness is not required. + +--- + +## Combatant Row Layout + +### User Stories + +**Story ROW-1 — Compact Resting State (P1)** +As a DM, I want each combatant row to display a minimal, uncluttered view at rest so I can scan the encounter list quickly during play. + +Acceptance scenarios: +1. **Given** a combatant row is not hovered, **Then** no delta input, action buttons, "+" condition button, remove button, or Brain icon are visible. +2. **Given** a combatant has no conditions, **Then** the row occupies exactly one line height at rest. +3. **Given** a combatant has conditions applied, **Then** condition icons appear inline after the creature name on the same row (not a separate line). + +**Story ROW-2 — Hover Reveals Controls (P1)** +As a DM, I want secondary controls to appear when I hover over a row so they are accessible without cluttering the resting view. + +Acceptance scenarios: +1. **Given** any combatant row, **When** hovered, **Then** the "+" condition button appears inline after the name/conditions. +2. **Given** any combatant row, **When** hovered, **Then** the remove (x) button becomes visible. +3. **Given** any combatant row, **When** hovered and concentration is inactive, **Then** the Brain icon becomes visible. +4. **Given** the remove button appears on hover, **Then** no layout shift occurs — space is reserved. + +**Story ROW-3 — Row Click Opens Stat Block (P1)** +As a DM, I want to click anywhere on a bestiary combatant row to open its stat block so I have a large click target and a cleaner row without a dedicated book icon. + +Acceptance scenarios: +1. **Given** a combatant has a linked bestiary creature, **When** the user clicks the name text or empty row space, **Then** the stat block panel opens. +2. **Given** the user clicks an interactive element (initiative, HP, AC, condition icon, "+", "x", concentration), **Then** the stat block does NOT open — the element's own action fires. +3. **Given** a combatant does NOT have a linked creature, **When** the row is clicked, **Then** nothing happens. +4. **Given** viewing any bestiary combatant row, **Then** no BookOpen icon is visible. +5. **Given** a bestiary combatant row, **When** the user hovers over non-interactive areas, **Then** the cursor indicates clickability. +6. **Given** the stat block is already open for a creature, **When** the same row is clicked, **Then** the panel closes (toggle behavior). + +### Requirements + +- **FR-080**: Condition icons MUST render inline after the creature name within the same row. +- **FR-081**: The "+" condition button MUST be hidden at rest and appear on row hover (or touch/focus on touch devices). +- **FR-082**: The remove (x) button MUST be hidden at rest and appear on row hover (or touch/focus on touch devices). +- **FR-083**: Layout space for the remove button MUST be reserved so appearing/disappearing does not cause layout shifts. +- **FR-084**: Clicking non-interactive areas of a bestiary combatant row MUST open the stat block panel. +- **FR-085**: Clicking interactive elements (initiative, HP, AC, conditions, "+", "x", concentration) MUST NOT trigger the stat block — only the element's own action. +- **FR-086**: The BookOpen icon MUST be removed from the combatant row. +- **FR-087**: Bestiary combatant rows MUST show a pointer cursor on hover over non-interactive areas. +- **FR-088**: All existing interactions (condition add/remove, HP adjustment, AC editing, initiative editing/rolling, concentration toggle, combatant removal) MUST continue to work. +- **FR-089**: Browser scrollbars MUST be styled to match the dark UI theme (thin, dark-colored scrollbar thumbs). +- **FR-090**: Turn navigation (Previous/Next) MUST use StepBack/StepForward icons in outline button style with foreground-colored borders. Utility actions (d20/trash) MUST use ghost button style to create clear visual hierarchy. +- **FR-091**: Previous and Next turn buttons MUST be positioned at the far left and far right of the top bar respectively, with the round/combatant info centered between them. +- **FR-092**: The "Initiative Tracker" heading MUST be removed to maximize vertical space for combatants. +- **FR-093**: All interactive elements in the row MUST remain keyboard-accessible (focusable and operable via keyboard). +- **FR-094**: On touch devices, hover-only controls ("+" button, "x" button) MUST remain accessible via tap or focus. + +### Edge Cases + +- When a combatant has so many conditions that they exceed the available inline space, they wrap within the name column; row height increases but width does not. +- The condition picker dropdown positions relative to the "+" button, flipping vertically if near the viewport edge. +- When the stat block panel is already open and the user clicks the same row again, the panel closes. +- Clicking the initiative area starts editing; it does not open the stat block. +- Tablet-width screens (>= 768 px / Tailwind `md`): popovers and inline edits MUST remain accessible and not overflow or clip. + +--- + +## Success Criteria *(mandatory)* + +- **SC-001**: Users can set max HP and adjust current HP for any combatant in under 5 seconds per action. +- **SC-002**: `currentHp` never exceeds `maxHp` or drops below 0, regardless of input method. +- **SC-003**: HP values survive a full page reload without data loss. +- **SC-004**: A user can apply damage to a combatant in 2 interactions or fewer (click HP -> type number -> Enter). +- **SC-005**: A user can apply healing in 3 interactions or fewer (click HP -> type number -> click Heal). +- **SC-006**: Users can identify bloodied combatants at a glance without reading HP numbers — 100% of combatants below half HP display a distinct amber treatment. +- **SC-007**: Users can identify unconscious/dead combatants at a glance — 100% of combatants at 0 HP or below display a distinct red treatment that differs from the bloodied indicator. +- **SC-008**: Visual status indicators update within the same interaction frame as the HP change. +- **SC-009**: Users can set an AC value for any combatant within the existing edit workflow with no additional steps. +- **SC-010**: AC is visible at a glance in the encounter list without expanding or hovering. +- **SC-011**: A condition can be added to a combatant in 2 clicks or fewer (click "+", click condition). +- **SC-012**: A condition can be removed in 1 click (click the active icon tag). +- **SC-013**: All 15 D&D 5e conditions are available and visually distinguishable by icon and color. +- **SC-014**: Condition state survives a full page reload without data loss. +- **SC-015**: Users can toggle concentration on/off for any combatant in a single click. +- **SC-016**: Concentrating combatants are visually distinguishable from non-concentrating combatants at a glance. +- **SC-017**: When a concentrating combatant takes damage, the visual pulse alert draws attention within the same interaction flow. +- **SC-018**: Concentration state survives a full page reload. +- **SC-019**: Users can set initiative for any combatant in a single action. +- **SC-020**: After any initiative change, the encounter list immediately reflects the correct descending sort. +- **SC-021**: A single combatant's initiative can be rolled with one click (the d20 button). +- **SC-022**: All eligible combatants' initiative can be rolled with one click (roll-all button). +- **SC-023**: Manual combatants (no stat block) are never affected by roll actions. +- **SC-024**: Every bestiary creature displays an initiative value in its stat block matching D&D Beyond / Monster Manual 2024. +- **SC-025**: The initiative line is visible without scrolling in the stat block header. +- **SC-026**: Each combatant row without conditions takes up exactly one line height at rest. +- **SC-027**: The DM can open a stat block by clicking anywhere on the combatant name area without needing a dedicated icon. +- **SC-028**: The AC number is visually identifiable as armor class through the shield shape alone. +- **SC-029**: No layout shift occurs when hovering/unhovering rows. +- **SC-030**: All HP, AC, initiative, condition, and concentration interactions remain fully operable using only a keyboard. diff --git a/specs/003-remove-combatant/checklists/requirements.md b/specs/003-remove-combatant/checklists/requirements.md deleted file mode 100644 index 48d0414..0000000 --- a/specs/003-remove-combatant/checklists/requirements.md +++ /dev/null @@ -1,34 +0,0 @@ -# Specification Quality Checklist: Remove Combatant - -**Purpose**: Validate specification completeness and quality before proceeding to planning -**Created**: 2026-03-03 -**Feature**: [spec.md](../spec.md) - -## Content Quality - -- [x] No implementation details (languages, frameworks, APIs) -- [x] Focused on user value and business needs -- [x] Written for non-technical stakeholders -- [x] All mandatory sections completed - -## Requirement Completeness - -- [x] No [NEEDS CLARIFICATION] markers remain -- [x] Requirements are testable and unambiguous -- [x] Success criteria are measurable -- [x] Success criteria are technology-agnostic (no implementation details) -- [x] All acceptance scenarios are defined -- [x] Edge cases are identified -- [x] Scope is clearly bounded -- [x] Dependencies and assumptions identified - -## Feature Readiness - -- [x] All functional requirements have clear acceptance criteria -- [x] User scenarios cover primary flows -- [x] Feature meets measurable outcomes defined in Success Criteria -- [x] No implementation details leak into specification - -## Notes - -- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`. diff --git a/specs/003-remove-combatant/data-model.md b/specs/003-remove-combatant/data-model.md deleted file mode 100644 index 92d4442..0000000 --- a/specs/003-remove-combatant/data-model.md +++ /dev/null @@ -1,69 +0,0 @@ -# Data Model: Remove Combatant - -**Feature**: 003-remove-combatant -**Date**: 2026-03-03 - -## Existing Entities (no changes) - -### Encounter - -| Field | Type | Description | -|-------|------|-------------| -| combatants | readonly Combatant[] | Ordered list of participants | -| activeIndex | number | Index of the combatant whose turn it is | -| roundNumber | number | Current round (≥ 1, never changes on removal) | - -### Combatant - -| Field | Type | Description | -|-------|------|-------------| -| id | CombatantId (branded string) | Unique identifier | -| name | string | Display name | - -## New Event Type - -### CombatantRemoved - -| Field | Type | Description | -|-------|------|-------------| -| type | "CombatantRemoved" (literal) | Event discriminant | -| combatantId | CombatantId | ID of the removed combatant | -| name | string | Name of the removed combatant | - -Added to the `DomainEvent` discriminated union alongside `TurnAdvanced`, `RoundAdvanced`, and `CombatantAdded`. - -## New Domain Function - -### removeCombatant - -| Parameter | Type | Description | -|-----------|------|-------------| -| encounter | Encounter | Current encounter state | -| id | CombatantId | ID of combatant to remove | - -**Returns**: `RemoveCombatantSuccess | DomainError` - -### RemoveCombatantSuccess - -| Field | Type | Description | -|-------|------|-------------| -| encounter | Encounter | Updated encounter after removal | -| events | DomainEvent[] | Exactly one CombatantRemoved event | - -### DomainError (existing, reused) - -Returned with code `"combatant-not-found"` when ID does not match any combatant. - -## State Transition Rules - -### activeIndex Adjustment - -Given removal of combatant at index `removedIdx` with current `activeIndex`: - -| Condition | New activeIndex | -|-----------|----------------| -| removedIdx > activeIndex | activeIndex (unchanged) | -| removedIdx < activeIndex | activeIndex - 1 | -| removedIdx === activeIndex, not last in list | activeIndex (next slides in) | -| removedIdx === activeIndex, last in list | 0 (wrap) | -| Only combatant removed (list becomes empty) | 0 | diff --git a/specs/003-remove-combatant/plan.md b/specs/003-remove-combatant/plan.md deleted file mode 100644 index c25fd15..0000000 --- a/specs/003-remove-combatant/plan.md +++ /dev/null @@ -1,71 +0,0 @@ -# Implementation Plan: Remove Combatant - -**Branch**: `003-remove-combatant` | **Date**: 2026-03-03 | **Spec**: [spec.md](./spec.md) -**Input**: Feature specification from `/specs/003-remove-combatant/spec.md` - -## Summary - -Add a `removeCombatant` pure domain function that removes a combatant by ID from an Encounter, correctly adjusts `activeIndex` to preserve turn integrity, keeps `roundNumber` unchanged, and emits a `CombatantRemoved` event. Wire through an application-layer use case and expose via a minimal UI remove action per combatant. - -## Technical Context - -**Language/Version**: TypeScript 5.x (strict mode, verbatimModuleSyntax) -**Primary Dependencies**: React 19, Vite -**Storage**: In-memory React state (local-first, single-user MVP) -**Testing**: Vitest -**Target Platform**: Web (localhost:5173 dev, production build via Vite) -**Project Type**: Web application (monorepo: packages/domain, packages/application, apps/web) -**Performance Goals**: N/A (local-first, small data sets) -**Constraints**: Domain must be pure (no I/O); layer boundaries enforced by automated script -**Scale/Scope**: Single-user, single encounter at a time - -## Constitution Check - -*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* - -| Principle | Status | Evidence | -|-----------|--------|----------| -| I. Deterministic Domain Core | PASS | `removeCombatant` is a pure function: same input → same output, no I/O | -| II. Layered Architecture | PASS | Domain function → use case → React hook/UI. No layer violations. | -| III. Agent Boundary | N/A | No agent layer involved in this feature | -| IV. Clarification-First | PASS | Spec fully specifies all activeIndex adjustment rules; no ambiguity | -| V. Escalation Gates | PASS | All functionality is within spec scope | -| VI. MVP Baseline Language | PASS | No permanent bans introduced | -| VII. No Gameplay Rules | PASS | Removal is encounter management, not gameplay mechanics | - -**Gate result**: PASS — no violations. - -## Project Structure - -### Documentation (this feature) - -```text -specs/003-remove-combatant/ -├── plan.md -├── research.md -├── data-model.md -├── quickstart.md -└── tasks.md -``` - -### Source Code (repository root) - -```text -packages/domain/src/ -├── remove-combatant.ts # Pure domain function -├── events.ts # Add CombatantRemoved to DomainEvent union -├── types.ts # Existing types (no changes expected) -├── index.ts # Re-export removeCombatant -└── __tests__/ - └── remove-combatant.test.ts # Acceptance scenarios from spec - -packages/application/src/ -├── remove-combatant-use-case.ts # Orchestrates store.get → domain → store.save -└── index.ts # Re-export use case - -apps/web/src/ -├── hooks/use-encounter.ts # Add removeCombatant callback -└── App.tsx # Add remove button per combatant + event display -``` - -**Structure Decision**: Follows the existing monorepo layered architecture (packages/domain → packages/application → apps/web) exactly mirroring the addCombatant feature's file layout. diff --git a/specs/003-remove-combatant/quickstart.md b/specs/003-remove-combatant/quickstart.md deleted file mode 100644 index cc6f362..0000000 --- a/specs/003-remove-combatant/quickstart.md +++ /dev/null @@ -1,39 +0,0 @@ -# Quickstart: Remove Combatant - -**Feature**: 003-remove-combatant - -## Prerequisites - -- Node.js 18+, pnpm -- Repository cloned, `pnpm install` run - -## Development - -```bash -git checkout 003-remove-combatant -pnpm test:watch # Run tests in watch mode during development -pnpm --filter web dev # Dev server at localhost:5173 -``` - -## Verification - -```bash -pnpm check # Must pass before commit (format + lint + typecheck + test) -``` - -## Implementation Order - -1. **Domain**: Add `CombatantRemoved` event type → implement `removeCombatant` pure function → tests -2. **Application**: Add `removeCombatantUseCase` → re-export -3. **Web**: Add `removeCombatant` to `useEncounter` hook → add remove button in `App.tsx` - -## Key Files - -| Layer | File | Purpose | -|-------|------|---------| -| Domain | `packages/domain/src/remove-combatant.ts` | Pure removal function | -| Domain | `packages/domain/src/events.ts` | CombatantRemoved event type | -| Domain | `packages/domain/src/__tests__/remove-combatant.test.ts` | Acceptance tests | -| Application | `packages/application/src/remove-combatant-use-case.ts` | Use case orchestration | -| Web | `apps/web/src/hooks/use-encounter.ts` | Hook integration | -| Web | `apps/web/src/App.tsx` | UI remove button | diff --git a/specs/003-remove-combatant/research.md b/specs/003-remove-combatant/research.md deleted file mode 100644 index e9acb05..0000000 --- a/specs/003-remove-combatant/research.md +++ /dev/null @@ -1,48 +0,0 @@ -# Research: Remove Combatant - -**Feature**: 003-remove-combatant -**Date**: 2026-03-03 - -## R1: activeIndex Adjustment Strategy on Removal - -**Decision**: Use positional comparison between removed index and activeIndex to determine adjustment. - -**Rationale**: The spec defines five distinct cases based on the relationship between the removed combatant's index and the current activeIndex. These map cleanly to a single conditional: - -1. **Removed index > activeIndex** → no change (combatant was after active) -2. **Removed index < activeIndex** → decrement activeIndex by 1 (shift left) -3. **Removed index === activeIndex and not last** → keep same index (next combatant slides into position) -4. **Removed index === activeIndex and last** → wrap to 0 -5. **Last remaining combatant removed** → activeIndex = 0 - -This mirrors the inverse of addCombatant's "always append, never adjust" approach — removal requires adjustment because positions shift. - -**Alternatives considered**: -- Storing active combatant by ID instead of index: Would simplify removal but requires changing the Encounter type (out of scope, breaks existing advanceTurn). -- Emitting a TurnAdvanced event on active removal: Rejected — spec explicitly says roundNumber is unchanged, and the next-in-line simply inherits. - -## R2: CombatantRemoved Event Shape - -**Decision**: Follow the existing event pattern with `type` discriminant. Include `combatantId` and `name` fields. - -**Rationale**: Consistent with `CombatantAdded` which carries `combatantId`, `name`, and `position`. For removal, `position` is less meaningful (the combatant is gone), so we include only ID and name. - -**Alternatives considered**: -- Including the removed index: Rejected — the index is ephemeral and not useful after the fact. -- Including the full Combatant object: Over-engineered for current needs; ID + name suffices. - -## R3: Use Case Pattern - -**Decision**: Mirror `addCombatantUseCase` exactly — `store.get()` → domain function → `store.save()` → return events. - -**Rationale**: No new patterns needed. The existing use case pattern handles the get-transform-save cycle cleanly. - -## R4: UI Pattern for Remove Action - -**Decision**: Add a remove button next to each combatant in the list. The button calls `removeCombatant(id)` from the hook. - -**Rationale**: Minimal UI per spec. No confirmation dialog needed for MVP (spec doesn't require it). Mirrors the simplicity of the existing add form. - -**Alternatives considered**: -- Confirmation modal before removal: MVP baseline does not include this; can be added later. -- Swipe-to-remove gesture: Not applicable for web MVP. diff --git a/specs/003-remove-combatant/spec.md b/specs/003-remove-combatant/spec.md deleted file mode 100644 index d5284bd..0000000 --- a/specs/003-remove-combatant/spec.md +++ /dev/null @@ -1,101 +0,0 @@ -# Feature Specification: Remove Combatant - -**Feature Branch**: `003-remove-combatant` -**Created**: 2026-03-03 -**Status**: Draft -**Input**: User description: "RemoveCombatant: allow removing a combatant by id from Encounter (adjust activeIndex correctly, keep roundNumber, emit CombatantRemoved, error if id not found) and wire through application + minimal UI." - -## User Scenarios & Testing *(mandatory)* - -### User Story 1 - Remove a Combatant from an Active Encounter (Priority: P1) - -A game master is running a combat encounter and a combatant is defeated or leaves. The GM removes that combatant by clicking a remove action. The combatant disappears from the initiative order and the turn continues correctly without disruption. - -**Why this priority**: Core functionality — removing combatants is the primary purpose of this feature and must work correctly to maintain encounter integrity. - -**Independent Test**: Can be fully tested by adding combatants to an encounter, removing one, and verifying the combatant list, activeIndex, and roundNumber are correct. - -**Acceptance Scenarios**: - -1. **Given** an encounter with combatants [A, B, C] and activeIndex 1 (B's turn), **When** the GM removes combatant C (index 2, after active), **Then** the encounter has [A, B], activeIndex remains 1, roundNumber unchanged, and a CombatantRemoved event is emitted. -2. **Given** an encounter with combatants [A, B, C] and activeIndex 2 (C's turn), **When** the GM removes combatant A (index 0, before active), **Then** the encounter has [B, C], activeIndex becomes 1 (still C's turn), roundNumber unchanged. -3. **Given** an encounter with combatants [A, B, C] and activeIndex 1 (B's turn), **When** the GM removes combatant B (the active combatant), **Then** the encounter has [A, C], activeIndex becomes 1 (C is now active — the next combatant takes over), roundNumber unchanged. -4. **Given** an encounter with combatants [A, B, C] and activeIndex 2 (C's turn, last position), **When** the GM removes combatant C (active and last), **Then** the encounter has [A, B], activeIndex wraps to 0 (A is now active), roundNumber unchanged. -5. **Given** an encounter with combatants [A] and activeIndex 0, **When** the GM removes combatant A, **Then** the encounter has [], activeIndex is 0, roundNumber unchanged. -6. **Given** an encounter with combatants [A, B, C], **When** the GM attempts to remove a combatant with an ID that does not exist, **Then** a domain error is returned with a descriptive error code, and the encounter is unchanged. - ---- - -### User Story 2 - Remove Combatant via UI (Priority: P2) - -A game master sees a list of combatants in the encounter UI. Each combatant has a remove action. Clicking it removes the combatant and the UI updates to reflect the new initiative order. - -**Why this priority**: Provides the user-facing interaction for the core domain functionality. Without UI, the feature is not accessible. - -**Independent Test**: Can be tested by rendering the encounter UI, clicking the remove action on a combatant, and verifying the combatant disappears from the list. - -**Acceptance Scenarios**: - -1. **Given** an encounter with combatants displayed in the UI, **When** the GM clicks the remove action on a combatant, **Then** that combatant is removed from the displayed list. -2. **Given** an encounter displayed in the UI, **When** a removal results in a domain error (ID not found), **Then** the removal is silently ignored and the encounter state remains unchanged. - ---- - -### Edge Cases - -- What happens when removing the only combatant? The encounter becomes empty with activeIndex 0. -- What happens when removing the active combatant who is last in the list? activeIndex wraps to 0. -- What happens when removing from an empty encounter? This is covered by the "ID not found" error since no combatant IDs exist. -- What happens if the same ID is passed twice in sequence? The first call succeeds; the second returns an error (ID not found). - -## Requirements *(mandatory)* - -### Functional Requirements - -- **FR-001**: System MUST remove a combatant identified by CombatantId from the encounter's combatant list. -- **FR-002**: System MUST return a domain error with code `"combatant-not-found"` when the given CombatantId does not match any combatant in the encounter. -- **FR-003**: System MUST preserve the roundNumber unchanged after removal. -- **FR-004**: System MUST adjust activeIndex so that the same combatant remains active after removal when the removed combatant is before the active one (activeIndex decrements by 1). -- **FR-005**: System MUST keep activeIndex unchanged when the removed combatant is after the active one. -- **FR-006**: System MUST advance activeIndex to the next combatant (same index position) when the active combatant is removed, allowing the next-in-line to take over. -- **FR-007**: System MUST wrap activeIndex to 0 when the active combatant is removed and it was the last in the list. -- **FR-008**: System MUST set activeIndex to 0 when the last remaining combatant is removed (empty encounter). -- **FR-009**: System MUST emit exactly one CombatantRemoved event on successful removal, containing the removed combatant's ID and name. -- **FR-010**: System MUST expose the remove-combatant operation through the application layer via a use case / port interface. -- **FR-011**: System MUST provide a UI control for each combatant that triggers removal. - -### Key Entities - -- **Encounter**: The combat encounter containing an ordered list of combatants, an activeIndex, and a roundNumber. -- **Combatant**: A participant in the encounter identified by a unique CombatantId and a name. -- **CombatantRemoved** (event): A domain event recording the removal, carrying the removed combatant's ID and name. - -## Success Criteria *(mandatory)* - -### Measurable Outcomes - -- **SC-001**: Removing a combatant from any position in the initiative order preserves correct turn tracking (the intended combatant remains or becomes active). -- **SC-002**: All six acceptance scenarios pass as automated tests. -- **SC-003**: The round number never changes as a result of removal. -- **SC-004**: The UI reflects combatant removal immediately after the action, with no stale state displayed. - -## Assumptions - -- ID generation and lookup is the caller's responsibility, consistent with the addCombatant pattern. -- Removal does not trigger a round advance — roundNumber is always preserved. -- The domain function is pure: deterministic given identical inputs, no I/O. -- The CombatantRemoved event follows the same plain-data-object pattern as existing domain events. -- When the active combatant is removed, the next combatant in order inherits the turn (no automatic turn advance or round increment occurs). -- Error feedback for invalid removal is a silent no-op for MVP. MVP baseline does not include user-visible error messages for removal failures. - -## Constitution Check - -| Principle | Status | Evidence | -|-----------|--------|----------| -| I. Deterministic Domain Core | PASS | removeCombatant is a pure state transition with no I/O | -| II. Layered Architecture | PASS | Domain function → use case → UI adapter | -| III. Agent Boundary | N/A | No agent layer involved | -| IV. Clarification-First | PASS | All activeIndex rules fully specified; no ambiguity | -| V. Escalation Gates | PASS | All requirements within original spec scope | -| VI. MVP Baseline Language | PASS | No permanent bans; confirmation dialog excluded via MVP baseline language | -| VII. No Gameplay Rules | PASS | Encounter management only, no game mechanics | diff --git a/specs/003-remove-combatant/tasks.md b/specs/003-remove-combatant/tasks.md deleted file mode 100644 index 4e1b97b..0000000 --- a/specs/003-remove-combatant/tasks.md +++ /dev/null @@ -1,117 +0,0 @@ -# Tasks: Remove Combatant - -**Input**: Design documents from `/specs/003-remove-combatant/` -**Prerequisites**: plan.md, spec.md, research.md, data-model.md - -**Tests**: Included — spec requires all six acceptance scenarios as automated tests (SC-002). - -**Organization**: Tasks grouped by user story for independent implementation and testing. - -## Format: `[ID] [P?] [Story] Description` - -- **[P]**: Can run in parallel (different files, no dependencies) -- **[Story]**: Which user story this task belongs to (e.g., US1, US2) -- Exact file paths included in descriptions - -## Phase 1: Foundational (Event Type) - -**Purpose**: Add the CombatantRemoved event type that all subsequent tasks depend on. - -- [x] T001 Add `CombatantRemoved` interface and extend `DomainEvent` union in `packages/domain/src/events.ts` -- [x] T002 Export `CombatantRemoved` type from `packages/domain/src/index.ts` - -**Checkpoint**: CombatantRemoved event type available for domain function and UI event display. - ---- - -## Phase 2: User Story 1 - Remove Combatant Domain Logic (Priority: P1) MVP - -**Goal**: Pure `removeCombatant` domain function that removes a combatant by ID, adjusts activeIndex correctly, preserves roundNumber, and emits CombatantRemoved. - -**Independent Test**: Call `removeCombatant` with various encounter states and verify combatant list, activeIndex, roundNumber, events, and error cases. - -### Tests for User Story 1 - -> **NOTE: Write these tests FIRST, ensure they FAIL before implementation** - -- [x] T003 [US1] Write acceptance tests for `removeCombatant` in `packages/domain/src/__tests__/remove-combatant.test.ts` covering all 6 spec scenarios: remove after active (AS-1), remove before active (AS-2), remove active combatant mid-list (AS-3), remove active combatant at end/wrap (AS-4), remove only combatant (AS-5), ID not found error (AS-6). Also test: event shape (CombatantRemoved with id+name), roundNumber invariance, and determinism. - -### Implementation for User Story 1 - -- [x] T004 [US1] Implement `removeCombatant` pure function and `RemoveCombatantSuccess` type in `packages/domain/src/remove-combatant.ts` — find combatant by ID, compute new activeIndex per data-model rules, filter combatant list, emit CombatantRemoved event, return DomainError for not-found -- [x] T005 [US1] Export `removeCombatant` and `RemoveCombatantSuccess` from `packages/domain/src/index.ts` - -**Checkpoint**: All 6 acceptance tests pass. Domain function is complete and independently testable. - ---- - -## Phase 3: User Story 2 - Application + UI Wiring (Priority: P2) - -**Goal**: Wire removeCombatant through application use case and expose via minimal UI with a remove button per combatant. - -**Independent Test**: Render encounter UI, click remove on a combatant, verify it disappears from the list and event log updates. - -### Implementation for User Story 2 - -- [x] T006 [P] [US2] Create `removeCombatantUseCase` in `packages/application/src/remove-combatant-use-case.ts` — follows existing pattern: `store.get()` → `removeCombatant()` → `store.save()` → return events or DomainError -- [x] T007 [US2] Export `removeCombatantUseCase` from `packages/application/src/index.ts` -- [x] T008 [US2] Add `removeCombatant(id: CombatantId)` callback to `useEncounter` hook in `apps/web/src/hooks/use-encounter.ts` — call use case, append events to log on success -- [x] T009 [US2] Add remove button per combatant and `CombatantRemoved` event display case in `apps/web/src/App.tsx` - -**Checkpoint**: Full vertical slice works — GM can remove combatants from UI, initiative order updates correctly, event log shows removal. - ---- - -## Phase 4: Polish & Cross-Cutting Concerns - -- [x] T010 Run `pnpm check` (format + lint + typecheck + test) and fix any issues - ---- - -## Dependencies & Execution Order - -### Phase Dependencies - -- **Phase 1 (Foundational)**: No dependencies — start immediately -- **Phase 2 (US1 Domain)**: Depends on Phase 1 (needs CombatantRemoved type) -- **Phase 3 (US2 App+UI)**: Depends on Phase 2 (needs domain function) -- **Phase 4 (Polish)**: Depends on Phase 3 - -### Within Each Phase - -- T001 → T002 (export after defining) -- T003 (tests first) → T004 (implement) → T005 (export) -- T006 → T007 (export after creating use case file) -- T008 depends on T006+T007 (needs use case) -- T009 depends on T008 (needs hook callback) - -### Parallel Opportunities - -- Within T003, individual test cases are independent - ---- - -## Implementation Strategy - -### MVP First (User Story 1 Only) - -1. Complete Phase 1: Event type (T001–T002) -2. Complete Phase 2: Domain tests + function (T003–T005) -3. **STOP and VALIDATE**: All 6 acceptance tests pass -4. Domain is complete and usable without UI - -### Full Feature - -1. Phase 1 → Phase 2 → Phase 3 → Phase 4 -2. Each phase adds a testable increment -3. Commit after each phase checkpoint - ---- - -## Notes - -- [P] tasks = different files, no dependencies -- [Story] label maps task to specific user story -- Tests written first (TDD) per spec requirement SC-002 -- Commit after each phase checkpoint -- Total: 10 tasks across 4 phases diff --git a/specs/004-bestiary/spec.md b/specs/004-bestiary/spec.md new file mode 100644 index 0000000..f6c038a --- /dev/null +++ b/specs/004-bestiary/spec.md @@ -0,0 +1,301 @@ +# Feature Specification: Bestiary + +**Feature Branch**: `004-bestiary` +**Created**: 2026-03-06 +**Status**: Implemented + +--- + +## Overview + +The Bestiary feature provides creature search across a pre-indexed library of 3,312+ creatures from 102+ D&D sources, stat block display for full creature reference during combat, source data management via on-demand fetch or file upload, and a dual-panel UX with fold/unfold and pin capabilities. Creatures can be added individually or in batch from a search dropdown, with stats pre-filled from the index. + +The architecture uses a two-tier design: a lightweight search index (`data/bestiary/index.json`) shipped with the app containing mechanical facts for all creatures, and full stat block data loaded on-demand at the source level, normalized, and cached in IndexedDB. + +**Structure**: This spec is organized by topic area. Each topic section contains its own user scenarios, requirements, and edge cases. + +--- + +## Search & Discovery + +### User Stories + +**US-S1 — Search and Add a Creature (P1)** +As a DM running an encounter, I want to search for a creature by name in the bestiary so that I can quickly add it as a combatant with its stats pre-filled (name, HP, AC), saving me from manually entering data. + +A search field in the bottom bar accepts typed queries. Matching creatures from the pre-shipped index appear in a dropdown, each labeled with its source display name (e.g., "Goblin (Monster Manual (2025))"). Selecting a creature adds it as a combatant — name, HP, AC, and initiative modifier are populated directly from the index without any network fetch. The search field displays action-oriented placeholder text (e.g., "Search creatures to add..."). + +**US-S2 — Batch Add Multiple Copies of a Creature (P1)** +As a DM, I want to quickly add multiple copies of the same creature from the bestiary so I can set up encounters with groups of identical monsters without repetitive searching and clicking. + +Clicking a dropdown entry once shows a count badge (starting at 1) and a confirm button on that row. Clicking the same entry again increments the count. Confirming adds N copies of that creature to combat and resets the queue. Only one creature type may be queued at a time. + +**US-S3 — Add a Custom Creature with Optional Stats (P2)** +As a DM, I want to type a custom creature name that doesn't match the bestiary and optionally provide initiative, AC, and max HP values so I can add homebrew or improvised creatures with pre-filled stats. + +When the search input has no bestiary matches (or fewer than 2 characters typed), optional input fields for initiative, AC, and max HP appear. The creature is addable with or without these fields filled in. + +### Requirements + +- **FR-001**: The app MUST ship a pre-generated search index (`data/bestiary/index.json`) containing creature name, source code, AC, HP average, DEX score, CR, initiative proficiency multiplier, size code, and creature type for all indexed creatures. +- **FR-002**: The app MUST include a source display name map translating source codes to human-readable names (e.g., "XMM" -> "Monster Manual (2025)"). +- **FR-003**: Search MUST operate against the full shipped index — case-insensitive substring match on creature name, minimum 2 characters, maximum 10 results, sorted alphabetically. +- **FR-004**: Search results MUST display the source display name alongside the creature name. +- **FR-005**: Adding a creature from search MUST populate name, HP, AC, and initiative data directly from the index without any network fetch. +- **FR-006**: The system MUST auto-number duplicate creature names (e.g., "Goblin 1", "Goblin 2") when multiple combatants share the same bestiary creature name. When a second copy is added, the existing combatant is renamed to include the suffix. +- **FR-007**: Auto-numbered names MUST remain editable via the existing rename functionality. +- **FR-008**: Combatants added from the bestiary MUST retain a link (`creatureId`) to their creature data so the stat block can be re-opened from the tracker. +- **FR-009**: The search field placeholder MUST display action-oriented hint text (e.g., "Search creatures to add..."). +- **FR-010**: Clicking a bestiary dropdown entry MUST show a count badge (starting at 1) and a confirm button on that row. +- **FR-011**: Clicking the same dropdown entry again MUST increment the count by 1. +- **FR-012**: Only one creature type MAY be queued at a time; selecting a different creature MUST replace the current queue. +- **FR-013**: Confirming the queue (via confirm button or Enter key) MUST add N copies of the selected creature to combat and reset the queue state. +- **FR-014**: When no bestiary match exists for the typed name, the system MUST show optional input fields for initiative, AC, and max HP, each with a visible label. +- **FR-015**: Custom creatures MUST be addable with or without the optional fields filled in; invalid numeric input MUST be treated as empty. + +### Acceptance Scenarios + +1. **Given** the app is loaded, **When** the DM types "gob" in the search field, **Then** results include goblins from multiple sources, each labeled with the source display name, sorted alphabetically, limited to 10 results. +2. **Given** search results are visible, **When** the DM selects "Goblin (Monster Manual (2025))", **Then** a combatant is added with the correct name, HP, AC, and initiative modifier — no network request is made. +3. **Given** the app is loaded, **When** the DM types a single character, **Then** no results appear (minimum 2 characters required). +4. **Given** search results are showing, **When** the user types a query with no matches (e.g., "zzzzz"), **Then** the dropdown shows a "No creatures found" message. +5. **Given** a combatant named "Goblin" already exists, **When** the user adds another Goblin from the bestiary, **Then** the existing combatant is renamed to "Goblin 1" and the new combatant is named "Goblin 2". +6. **Given** an auto-numbered combatant "Goblin 2" exists, **When** the user edits its name, **Then** the name updates as usual (renaming is not blocked by auto-numbering). +7. **Given** the dropdown is showing results, **When** the user clicks on a creature entry, **Then** a count badge showing "1" and a confirm button appear on that row. +8. **Given** a creature entry shows a count of N, **When** the user clicks that same entry again, **Then** the count increments to N+1. +9. **Given** a creature is queued with count N, **When** the user clicks the confirm button or presses Enter, **Then** N copies of that creature are added to combat and the queue resets. +10. **Given** the user types a name with no bestiary match, **When** the dropdown shows no results, **Then** optional input fields for initiative, AC, and max HP appear with visible labels. +11. **Given** the optional fields are visible, **When** the user leaves all optional fields empty and submits, **Then** the creature is added with only the name (no stats pre-filled). +12. **Given** the search input is open, **When** the user presses Escape, **Then** the search closes without adding a combatant. + +### Edge Cases + +- Two creatures from different sources sharing the same name: the source tag is shown alongside the name in search results. +- Queued creature removed from results when search query changes: the queue resets when the queued creature is no longer visible in the results. +- User presses Escape with a queued creature: the queue resets and the dropdown closes. +- Non-numeric input in optional custom creature fields: treated as empty (ignored). + +--- + +## Stat Block Display + +### User Stories + +**US-D1 — View Full Stat Block in Side Panel (P2)** +As a DM, I want to see the full stat block of a creature displayed in a side panel so that I can reference its abilities, actions, and traits during combat without switching to another tool. + +When a creature is selected from search results or when clicking a bestiary-linked combatant in the tracker, a stat block panel appears showing the creature's full information in the classic D&D stat block layout. Clicking a different combatant updates the panel to that creature's data. + +**US-D2 — Preview Stat Block from Search Dropdown (P3)** +As a DM, I want to preview a creature's stat block directly from the search dropdown so I can review creature details before deciding to add them to the encounter. + +A view button in the search bar (repurposed from the current search icon) opens the stat block panel for the currently focused/highlighted creature in the dropdown without committing to adding it. + +**US-D3 — Responsive Layout (P4)** +As a DM using the app on different devices, I want the layout to adapt between side-by-side (desktop) and drawer (mobile) so that the stat block is usable regardless of screen size. + +### Requirements + +- **FR-016**: The system MUST display a stat block panel with full creature information when a creature is selected. +- **FR-017**: The stat block MUST include: name, size, type, alignment, AC (with armor source if applicable), HP (average + formula), speed, ability scores with modifiers, saving throws, skills, damage vulnerabilities, damage resistances, damage immunities, condition immunities, senses, languages, challenge rating, proficiency bonus, passive perception, traits, actions, bonus actions, reactions, spellcasting, and legendary actions. +- **FR-018**: Optional stat block sections (traits, legendary actions, bonus actions, reactions, etc.) MUST be omitted entirely when the creature has none. +- **FR-019**: The system MUST strip bestiary markup tags (spell references, dice notation, attack tags) and render them as plain readable text (e.g., `{@spell fireball|XPHB}` -> "fireball", `{@dice 3d6}` -> "3d6"). +- **FR-020**: On wide viewports (desktop), the layout MUST be side-by-side with the encounter tracker on the left and stat block on the right. +- **FR-021**: On narrow viewports (mobile), the stat block MUST appear as a dismissible drawer or slide-over. +- **FR-022**: The stat block panel MUST scroll independently of the encounter tracker. +- **FR-023**: When the user clicks a different bestiary-linked combatant in the tracker, the stat block panel MUST update to show that creature's data. +- **FR-024**: The existing search/view button MUST be repurposed to open the stat block for the currently focused/selected creature in the dropdown. + +### Acceptance Scenarios + +1. **Given** a creature is selected from the bestiary search, **When** the stat block panel opens, **Then** it displays: name, size, type, alignment, AC, HP (average and formula), speed, ability scores with modifiers, saving throws, skills, damage resistances/immunities, condition immunities, senses, languages, challenge rating, traits, actions, and legendary actions (if applicable). +2. **Given** the stat block panel is open on desktop (wide viewport), **Then** the layout is side-by-side: encounter tracker on the left, stat block panel on the right. +3. **Given** the stat block panel is open on mobile (narrow viewport), **Then** the stat block appears as a slide-over drawer that can be dismissed. +4. **Given** a stat block is displayed, **When** the user clicks a different bestiary-linked combatant in the tracker, **Then** the stat block panel updates to show that creature's data. +5. **Given** a creature entry contains markup tags (e.g., spell references, dice notation), **Then** they render as plain text. +6. **Given** the dropdown is showing bestiary results, **When** the user clicks the stat block view button, **Then** the stat block panel opens for the currently focused/highlighted creature in the dropdown. +7. **Given** no creature is focused in the dropdown, **When** the user clicks the stat block view button, **Then** nothing happens (button is disabled or no-op). + +### Edge Cases + +- Creatures with no traits or legendary actions: those sections are omitted from the stat block display. +- Very long content (e.g., a Lich with extensive spellcasting): the stat block panel scrolls independently of the encounter tracker. +- Viewport resized from wide to narrow while stat block is open: the layout transitions from panel to drawer. + +--- + +## Source Management + +### User Stories + +**US-M1 — View Full Stat Block via On-Demand Source Fetch (P2)** +A DM clicks to view the stat block of a creature whose source data has not been loaded yet. The app displays a prompt: "Load [Source Display Name] bestiary data?" with a pre-filled URL pointing to the raw source file. The DM confirms, the app fetches the JSON, normalizes it, and caches all creatures from that source in IndexedDB. For any subsequent creature from the same source, the stat block appears instantly without prompting. + +**US-M2 — Manual File Upload as Fetch Alternative (P3)** +A DM who cannot access the URL (corporate firewall, offline use) uses a file upload option to load bestiary data from a local JSON file. The file is processed identically to a fetched file — normalized and cached by source. + +**US-M3 — Bulk Load All Sources (P1)** +The user wants to pre-load all bestiary sources at once so that every creature's stat block is instantly available without per-source fetch prompts during gameplay. An import button in the top bar opens the stat block side panel with a bulk import prompt, showing the dynamic source count, an editable pre-filled base URL, and a "Load All" button. All source files are fetched concurrently; already-cached sources are skipped. + +**US-M4 — Progress Feedback During Bulk Import (P1)** +While the bulk import is in progress, the user sees a text counter ("Loading sources... 34/102") and a progress bar in the side panel, giving them confidence the operation is proceeding. + +**US-M5 — Toast Notification on Panel Close During Import (P2)** +If the user closes the side panel while a bulk import is still in progress, a persistent toast notification appears at the bottom-center of the screen showing the same progress text and progress bar. + +**US-M6 — Manage Cached Sources (P4)** +A DM wants to see which sources are cached, clear a specific source's cache, or clear all cached data. A management UI provides this visibility and control. + +### Requirements + +- **FR-025**: When a user views a stat block for a creature whose source is not cached, the app MUST display a prompt to load the source data. +- **FR-026**: The source fetch prompt MUST include an editable URL field pre-filled with the default URL for that source's raw data file. +- **FR-027**: The source fetch prompt MUST appear once per source, not once per creature. After fetching a source, all its creatures' stat blocks become available. +- **FR-028**: On confirmation, the app MUST fetch the JSON, normalize it through the existing normalization pipeline, and cache all creatures from that source in IndexedDB. +- **FR-029**: Cached source data MUST persist across browser sessions using IndexedDB. +- **FR-030**: The app MUST provide a file upload option as an alternative to URL fetching, processing the uploaded file identically to a fetch. +- **FR-031**: If a fetch or upload fails, the app MUST show a user-friendly error message with options to retry or change the URL. The creature's index data (HP, AC, etc.) MUST remain intact in the encounter. +- **FR-032**: If the fetched JSON does not match the expected format, an error is shown to the user. +- **FR-033**: If persistent client-side storage is unavailable (private browsing, storage full), the app MUST fall back to in-memory caching for the current session and warn the user that data will not persist. +- **FR-034**: An import button (Lucide Import icon) in the top bar MUST open the stat block side panel with the bulk import prompt. +- **FR-035**: The bulk import prompt MUST show a descriptive text explaining the operation, including approximate data volume (~12.5 MB) and the dynamic number of sources derived from the bestiary index at runtime. +- **FR-036**: The system MUST pre-fill a base URL that the user can edit. +- **FR-037**: The system MUST construct individual fetch URLs by appending `bestiary-{sourceCode}.json` to the base URL for each source. +- **FR-038**: All fetch requests during bulk import MUST fire concurrently (browser handles connection pooling). +- **FR-039**: Already-cached sources MUST be skipped during bulk import. +- **FR-040**: The system MUST show a text counter ("Loading sources... N/T") and progress bar during bulk import. +- **FR-041**: When the user closes the side panel during an active bulk import, a toast notification MUST appear at the bottom-center of the screen showing the progress counter and progress bar. +- **FR-042**: On full success, the toast MUST auto-dismiss after a few seconds. On partial failure, the toast MUST remain visible until manually dismissed. +- **FR-043**: The toast system MUST be a lightweight custom component — no third-party toast library. +- **FR-044**: The bulk import MUST run asynchronously and not block the rest of the app. +- **FR-045**: The user MUST explicitly provide/confirm the URL before any fetches occur — the app never auto-fetches content. +- **FR-046**: The "Load All" button MUST be disabled when the URL field is empty or while a bulk import is already in progress. +- **FR-047**: The app MUST provide a management UI showing cached sources with options to clear individual sources or all cached data. +- **FR-048**: The normalization adapter and tag-stripping utility MUST remain the canonical pipeline for all fetched and uploaded data. +- **FR-049**: The distributed app bundle MUST contain zero copyrighted prose content — only mechanical facts and creature names in the search index. + +### Acceptance Scenarios + +1. **Given** a creature from an uncached source is in the encounter, **When** the DM opens its stat block, **Then** a prompt appears asking to load the source data with an editable URL field pre-filled with the correct raw file URL. +2. **Given** the fetch prompt is visible, **When** the DM confirms the fetch, **Then** the app downloads the JSON, normalizes it, caches all creatures from that source, and displays the stat block. +3. **Given** source data for a source has been cached, **When** the DM opens the stat block for any other creature from that source, **Then** the stat block displays instantly with no prompt. +4. **Given** the fetch prompt is visible, **When** the DM edits the URL to point to a mirror or local server, **Then** the app fetches from the edited URL instead. +5. **Given** a creature is in the encounter, **When** the DM opens its stat block and the source is not cached, **Then** the creature's index data (HP, AC, etc.) remains visible in the combatant row regardless of fetch outcome. +6. **Given** the source fetch prompt is visible, **When** the DM chooses "Upload file" and selects a valid bestiary JSON, **Then** the app normalizes and caches the data, and stat blocks become available. +7. **Given** the DM uploads an invalid or malformed JSON file, **When** the upload completes, **Then** the app shows a user-friendly error message and allows retry. +8. **Given** no sources are cached, **When** the user clicks the import button in the top bar, **Then** the stat block side panel opens showing a descriptive explanation, an editable pre-filled base URL, and a "Load All" button. +9. **Given** the bulk import prompt is visible, **When** the user clicks "Load All", **Then** the app fires fetch requests for all sources concurrently, normalizes each response, and caches results in IndexedDB. +10. **Given** some sources are already cached, **When** the user initiates a bulk import, **Then** already-cached sources are skipped and only uncached sources are fetched. +11. **Given** a bulk import is in progress, **When** the user views the side panel, **Then** they see a text counter (e.g., "Loading sources... 34/102") and a visual progress bar. +12. **Given** each source finishes loading, **Then** the counter and progress bar update immediately. +13. **Given** a bulk import is in progress, **When** the user closes the side panel, **Then** a toast notification appears at the bottom-center showing the progress counter and progress bar. +14. **Given** the toast is visible and all sources finish loading successfully, **Then** the toast shows "All sources loaded" and auto-dismisses after a few seconds. +15. **Given** the toast is visible and some sources fail, **Then** the toast shows "Loaded N/T sources (F failed)" and remains visible until the user dismisses it. +16. **Given** two sources have been cached, **When** the DM opens the source management UI, **Then** both sources are listed with their display names. +17. **Given** the source management UI is open, **When** the DM clears a single source, **Then** that source's data is removed; stat blocks for its creatures require re-fetching, while other cached sources remain. +18. **Given** the source management UI is open, **When** the DM clears all cached data, **Then** all source data is removed and all stat blocks require re-fetching. + +### Edge Cases + +- Network fetch fails mid-download: the app shows an error with the option to retry or change the URL; the creature remains in the encounter with its index data intact. +- Fetched JSON does not match expected format: normalization failure shows an error to the user. +- User adds a creature, caches its source, then clears the cache: the creature remains in the encounter with its index data; opening the stat block triggers the fetch prompt again. +- Storage unavailable (private browsing, storage full): fall back to in-memory caching for the current session with a warning. +- Browser is offline: the fetch prompt is shown but the fetch fails; the DM can use the file upload alternative; previously cached sources remain available. +- "Load All" clicked while a bulk import is already in progress: button is disabled during an active import. +- All sources already cached before bulk import: the operation completes immediately and reports "All sources loaded". +- Network completely unavailable during bulk import: all fetches fail; result shows "Loaded 0/T sources (T failed)". +- User navigates away or refreshes during import: partially completed caches persist; the user can re-run to pick up remaining sources. +- Base URL is empty or invalid: the "Load All" button is disabled. + +--- + +## Panel UX (Fold, Pin, Second Panel) + +### User Stories + +**US-P1 — Fold and Unfold Stat Block Panel (P1)** +As a DM running an encounter, I want to collapse the stat block panel to a slim tab so I can temporarily reclaim screen space without losing my place, then quickly expand it again to reference creature stats. + +The close button is replaced with a fold/unfold toggle. Folding slides the panel out to the right edge, leaving a slim vertical tab displaying the creature's name. Clicking the tab unfolds the panel, showing the same creature that was displayed before folding. No "Stat Block" heading text is shown in the panel header. + +**US-P2 — Pin Creature to Second Panel (P2)** +As a DM comparing creatures or referencing one creature while browsing others, I want to pin the current creature to a secondary panel on the left side of the screen so I can keep it visible while browsing different creatures in the right panel. + +Clicking the pin button copies the current creature to a new left panel. The right panel remains active for browsing different creatures independently. The left panel has an unpin button that removes it. + +**US-P3 — Fold Behavior with Pinned Panel (P3)** +As a DM with a creature pinned, I want to fold the right (browse) panel independently so I can focus on just the pinned creature, or fold both panels to see the full encounter list. + +### Requirements + +- **FR-050**: The system MUST replace the close button on the stat block panel with a fold/unfold toggle control. +- **FR-051**: The system MUST remove the "Stat Block" heading text from the panel header. +- **FR-052**: When folded, the panel MUST collapse to a slim vertical tab anchored to the right edge of the screen displaying the creature's name. +- **FR-053**: Folding and unfolding MUST use a smooth CSS slide animation (~200ms ease-out). +- **FR-054**: The fold/unfold toggle MUST preserve the currently displayed creature — unfolding shows the same creature that was visible when folded. +- **FR-055**: The panel MUST include a pin button that copies the current creature to a new panel on the left side of the screen. +- **FR-056**: After pinning, the right panel MUST remain active for browsing different creatures independently. +- **FR-057**: The pinned (left) panel MUST include an unpin button that removes it when clicked. +- **FR-058**: The pin button MUST be hidden on viewports where dual panels would not fit (small screens / mobile). +- **FR-059**: The pin button MUST be hidden when the panel is showing a source fetch prompt (no creature data displayed yet). +- **FR-060**: On mobile viewports, the existing drawer/backdrop behavior MUST be preserved — fold/unfold replaces only the close button behavior for the desktop layout; the backdrop click still dismisses the panel. +- **FR-061**: Both the browse (right) and pinned (left) panels MUST have independent fold states. + +### Acceptance Scenarios + +1. **Given** the stat block panel is open showing a creature, **When** the user clicks the fold button, **Then** the panel slides out to the right edge and a slim vertical tab appears showing the creature's name. +2. **Given** the stat block panel is folded to a tab, **When** the user clicks the tab, **Then** the panel slides back in showing the same creature that was displayed before folding. +3. **Given** the stat block panel is open, **When** the user looks for a close button, **Then** no close button is present — only a fold toggle. +4. **Given** the stat block panel is open, **When** the user looks at the panel header, **Then** no "Stat Block" heading text is visible. +5. **Given** the panel is folding or unfolding, **When** the animation plays, **Then** it completes with a smooth slide transition (~200ms ease-out). +6. **Given** the stat block panel is showing a creature on a wide screen, **When** the user clicks the pin button, **Then** the current creature appears in a new panel on the left side of the screen. +7. **Given** a creature is pinned to the left panel, **When** the user clicks a different combatant in the encounter list, **Then** the right panel updates to show the new creature while the left panel continues showing the pinned creature. +8. **Given** a creature is pinned to the left panel, **When** the user clicks the unpin button on the left panel, **Then** the left panel is removed and only the right panel remains. +9. **Given** the user is on a small screen or mobile viewport, **When** the stat block panel is displayed, **Then** the pin button is not visible. +10. **Given** both pinned (left) and browse (right) panels are open, **When** the user folds the right panel, **Then** the left pinned panel remains visible and the right panel collapses to a tab. +11. **Given** the right panel is folded and the left panel is pinned, **When** the user unfolds the right panel, **Then** it slides back showing the last browsed creature. + +### Edge Cases + +- User pins a creature and then that creature is removed from the encounter: the pinned panel continues displaying the creature's stat block (data is already loaded). +- User folds the panel and then the active combatant changes (auto-show logic on desktop): the panel stays folded but updates the selected creature internally; unfolding shows the current active combatant's stat block. The fold state is respected — advancing turns does not override a user-chosen fold. +- Viewport resized from wide to narrow while a creature is pinned: the pinned (left) panel is hidden and the pin button disappears; resizing back to wide restores the pinned panel. +- User is in bulk import mode and tries to fold: the fold/unfold behavior applies to the bulk import panel identically. +- Panel showing a source fetch prompt: the pin button is hidden. + +--- + +## Key Entities + +- **Search Index** (`BestiaryIndex`): Pre-shipped lightweight dataset keyed by name + source, containing mechanical facts (name, source code, AC, HP average, DEX score, CR, initiative proficiency multiplier, size, type) for all creatures. Sufficient for adding combatants; insufficient for rendering a full stat block. +- **Source** (`BestiarySource`): A D&D publication identified by a code (e.g., "XMM") with a display name (e.g., "Monster Manual (2025)"). Caching and fetching operate at the source level. +- **Creature (Full)** (`Creature`): A complete creature record with all stat block data (traits, actions, legendary actions, spellcasting, etc.), available only after source data is fetched/uploaded and cached. Identified by a branded `CreatureId`. +- **Cached Source Data**: The full normalized bestiary data for a single source, stored in IndexedDB. Contains complete creature stat blocks. +- **Combatant** (extended): Gains an optional `creatureId` reference to a `Creature`, enabling stat block lookup and stat pre-fill on creation. +- **Queued Creature**: Transient UI-only state representing a bestiary creature selected for batch-add, containing the creature reference and a count (1+). Not persisted. +- **Bulk Import Operation**: Tracks total sources, completed count, failed count, and current status (idle / loading / complete / partial-failure). +- **Toast Notification**: Lightweight custom UI element at bottom-center of screen with text, optional progress bar, and optional dismiss button. +- **Panel State**: Represents whether a stat block panel is expanded, folded, or absent. The browse (right) and pinned (left) panels each have independent state. + +--- + +## Success Criteria *(mandatory)* + +- **SC-001**: All 3,312+ indexed creatures are searchable immediately on app load, with search results appearing within 100ms of typing. +- **SC-002**: Adding a creature from search to the encounter completes without any network request and within 200ms. +- **SC-003**: After a source is cached, stat blocks for any creature from that source display within 200ms with no additional prompt. +- **SC-004**: The distributed app bundle contains zero copyrighted prose content — only mechanical facts and creature names in the search index. +- **SC-005**: Source data import (fetch or upload) for a typical source completes and becomes usable within 5 seconds on a standard broadband connection. +- **SC-006**: Cached data persists across browser sessions — closing and reopening the browser does not require re-fetching previously loaded sources. +- **SC-007**: The app bundle size is smaller than a bundled-full-bestiary approach, shipping only the lightweight index. +- **SC-008**: A DM can add 4 identical creatures to combat in 3 steps: type search query, click creature entry 4 times to set count, confirm — down from 4 separate search-and-add cycles. +- **SC-009**: All stat block sections render correctly for all creatures (no missing data, no raw markup tags visible). +- **SC-010**: The stat block panel is readable and fully functional on viewports from 375px (mobile) to 2560px (ultrawide) without horizontal scrolling or content clipping. +- **SC-011**: Users can load all bestiary sources with a single confirmation action; real-time progress is visible during the operation. +- **SC-012**: Already-cached sources are skipped during bulk import, reducing redundant data transfer on repeat imports. +- **SC-013**: The rest of the app remains fully interactive during the bulk import operation. +- **SC-014**: Users can fold the stat block panel in a single click and unfold it in a single click, with the transition completing in under 300ms. +- **SC-015**: Users can pin a creature and browse a different creature's stat block simultaneously, without any additional navigation steps. +- **SC-016**: The pinned panel is never visible on screens narrower than the dual-panel breakpoint, ensuring mobile usability is not degraded. +- **SC-017**: All fold/unfold and pin/unpin interactions are discoverable through visible button controls without requiring documentation or tooltips. diff --git a/specs/004-edit-combatant/checklists/requirements.md b/specs/004-edit-combatant/checklists/requirements.md deleted file mode 100644 index 2406d17..0000000 --- a/specs/004-edit-combatant/checklists/requirements.md +++ /dev/null @@ -1,34 +0,0 @@ -# Specification Quality Checklist: Edit Combatant - -**Purpose**: Validate specification completeness and quality before proceeding to planning -**Created**: 2026-03-03 -**Feature**: [spec.md](../spec.md) - -## Content Quality - -- [x] No implementation details (languages, frameworks, APIs) -- [x] Focused on user value and business needs -- [x] Written for non-technical stakeholders -- [x] All mandatory sections completed - -## Requirement Completeness - -- [x] No [NEEDS CLARIFICATION] markers remain -- [x] Requirements are testable and unambiguous -- [x] Success criteria are measurable -- [x] Success criteria are technology-agnostic (no implementation details) -- [x] All acceptance scenarios are defined -- [x] Edge cases are identified -- [x] Scope is clearly bounded -- [x] Dependencies and assumptions identified - -## Feature Readiness - -- [x] All functional requirements have clear acceptance criteria -- [x] User scenarios cover primary flows -- [x] Feature meets measurable outcomes defined in Success Criteria -- [x] No implementation details leak into specification - -## Notes - -- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`. diff --git a/specs/004-edit-combatant/contracts/domain-contract.md b/specs/004-edit-combatant/contracts/domain-contract.md deleted file mode 100644 index 7c2b75c..0000000 --- a/specs/004-edit-combatant/contracts/domain-contract.md +++ /dev/null @@ -1,37 +0,0 @@ -# Domain Contract: editCombatant - -## Function Signature - -``` -editCombatant(encounter, id, newName) → EditCombatantSuccess | DomainError -``` - -### Inputs - -| Parameter | Type | Description | -|-----------|------|-------------| -| encounter | Encounter | Current encounter state | -| id | CombatantId | Identity of combatant to rename | -| newName | string | New name to assign | - -### Success Output - -| Field | Type | Description | -|-------|------|-------------| -| encounter | Encounter | Updated encounter with renamed combatant | -| events | DomainEvent[] | Exactly one `CombatantUpdated` event | - -### Error Output - -| Code | Condition | -|------|-----------| -| `"combatant-not-found"` | No combatant with given id exists | -| `"invalid-name"` | newName is empty or whitespace-only | - -## Hook Contract - -`useEncounter()` returns an additional action: - -| Method | Signature | Description | -|--------|-----------|-------------| -| editCombatant | `(id: CombatantId, newName: string) => void` | Rename combatant, append events on success | diff --git a/specs/004-edit-combatant/data-model.md b/specs/004-edit-combatant/data-model.md deleted file mode 100644 index e7b821f..0000000 --- a/specs/004-edit-combatant/data-model.md +++ /dev/null @@ -1,59 +0,0 @@ -# Data Model: Edit Combatant - -**Feature**: 004-edit-combatant -**Date**: 2026-03-03 - -## Entities - -### Combatant (unchanged) - -| Field | Type | Notes | -|-------|------|-------| -| id | CombatantId (branded string) | Immutable identity | -| name | string | Mutable — this feature updates it | - -### Encounter (unchanged structure) - -| Field | Type | Notes | -|-------|------|-------| -| combatants | readonly Combatant[] | Edit replaces name in-place by mapping | -| activeIndex | number | Preserved during edit | -| roundNumber | number | Preserved during edit | - -## Events - -### CombatantUpdated (new) - -| Field | Type | Notes | -|-------|------|-------| -| type | "CombatantUpdated" | Discriminant | -| combatantId | CombatantId | Which combatant was renamed | -| oldName | string | Name before edit | -| newName | string | Name after edit | - -Added to the `DomainEvent` union type. - -## State Transitions - -### editCombatant(encounter, id, newName) - -**Preconditions**: -- `newName` is non-empty and not whitespace-only -- `id` matches a combatant in `encounter.combatants` - -**Postconditions**: -- The combatant with matching `id` has `name` set to `newName` -- `activeIndex` and `roundNumber` unchanged -- Combatant list order unchanged -- Exactly one `CombatantUpdated` event emitted - -**Error cases**: -- `id` not found → `DomainError { code: "combatant-not-found" }` -- `newName` empty/whitespace → `DomainError { code: "invalid-name" }` - -## Validation Rules - -| Rule | Condition | Error Code | -|------|-----------|------------| -| Name must be non-empty | `newName.trim().length === 0` | `"invalid-name"` | -| Combatant must exist | No combatant with matching `id` | `"combatant-not-found"` | diff --git a/specs/004-edit-combatant/plan.md b/specs/004-edit-combatant/plan.md deleted file mode 100644 index 148c7f7..0000000 --- a/specs/004-edit-combatant/plan.md +++ /dev/null @@ -1,70 +0,0 @@ -# Implementation Plan: Edit Combatant - -**Branch**: `004-edit-combatant` | **Date**: 2026-03-03 | **Spec**: [spec.md](./spec.md) -**Input**: Feature specification from `/specs/004-edit-combatant/spec.md` - -## Summary - -Add the ability to rename a combatant by id within an encounter. A pure domain function `editCombatant` validates the id and new name, returns the updated encounter with a `CombatantUpdated` event, or a `DomainError`. Wired through an application use case and exposed via the existing `useEncounter` hook to a minimal UI control. - -## Technical Context - -**Language/Version**: TypeScript 5.x (strict mode, verbatimModuleSyntax) -**Primary Dependencies**: React 19, Vite -**Storage**: In-memory React state (local-first, single-user MVP) -**Testing**: Vitest -**Target Platform**: Browser (localhost:5173) -**Project Type**: Web application (monorepo: domain → application → web) -**Performance Goals**: N/A — single-user local state, instant updates -**Constraints**: Pure domain logic, no I/O in domain layer -**Scale/Scope**: Single-user encounter tracker - -## Constitution Check - -*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* - -| Principle | Status | Notes | -|-----------|--------|-------| -| I. Deterministic Domain Core | PASS | `editCombatant` is a pure function: same encounter + id + name → same result | -| II. Layered Architecture | PASS | Domain function → use case → hook/UI. No layer violations. | -| III. Agent Boundary | N/A | No agent layer involvement | -| IV. Clarification-First | PASS | Spec is complete, no ambiguities remain | -| V. Escalation Gates | PASS | All work is within spec scope | -| VI. MVP Baseline Language | PASS | Spec uses "MVP baseline does not include" for out-of-scope items | -| VII. No Gameplay Rules | PASS | Constitution contains no gameplay logic | - -## Project Structure - -### Documentation (this feature) - -```text -specs/004-edit-combatant/ -├── plan.md -├── research.md -├── data-model.md -├── quickstart.md -├── contracts/ -└── tasks.md -``` - -### Source Code (repository root) - -```text -packages/domain/src/ -├── edit-combatant.ts # New: pure editCombatant function -├── events.ts # Modified: add CombatantUpdated event -├── types.ts # Unchanged (Combatant, Encounter, DomainError) -├── index.ts # Modified: re-export editCombatant -└── __tests__/ - └── edit-combatant.test.ts # New: acceptance + invariant tests - -packages/application/src/ -├── edit-combatant-use-case.ts # New: use case wiring -└── index.ts # Modified: re-export use case - -apps/web/src/ -├── hooks/use-encounter.ts # Modified: add editCombatant action -└── App.tsx # Modified: add rename UI control -``` - -**Structure Decision**: Follows the existing monorepo layout (`packages/domain` → `packages/application` → `apps/web`). Each new file mirrors the pattern established by `add-combatant` and `remove-combatant`. diff --git a/specs/004-edit-combatant/quickstart.md b/specs/004-edit-combatant/quickstart.md deleted file mode 100644 index c97197e..0000000 --- a/specs/004-edit-combatant/quickstart.md +++ /dev/null @@ -1,41 +0,0 @@ -# Quickstart: Edit Combatant - -**Feature**: 004-edit-combatant - -## Setup - -```bash -pnpm install # Install dependencies (if needed) -pnpm check # Verify everything passes before starting -``` - -## Development - -```bash -pnpm --filter web dev # Start dev server at localhost:5173 -pnpm test:watch # Run tests in watch mode -``` - -## Implementation Order - -1. **Domain event** — Add `CombatantUpdated` to `events.ts` -2. **Domain function** — Create `edit-combatant.ts` with pure `editCombatant` function -3. **Domain tests** — Create `edit-combatant.test.ts` with acceptance scenarios + invariants -4. **Domain exports** — Re-export from `index.ts` -5. **Application use case** — Create `edit-combatant-use-case.ts` -6. **Application exports** — Re-export from `index.ts` -7. **Hook** — Add `editCombatant` action to `useEncounter` hook -8. **UI** — Add inline name editing to `App.tsx` - -## Verification - -```bash -pnpm check # Must pass — format + lint + typecheck + test -``` - -## Key Files to Reference - -- `packages/domain/src/add-combatant.ts` — Pattern to follow for domain function -- `packages/domain/src/remove-combatant.ts` — Pattern for "not found" error handling -- `packages/application/src/add-combatant-use-case.ts` — Pattern for use case -- `apps/web/src/hooks/use-encounter.ts` — Pattern for hook wiring diff --git a/specs/004-edit-combatant/research.md b/specs/004-edit-combatant/research.md deleted file mode 100644 index d6518b4..0000000 --- a/specs/004-edit-combatant/research.md +++ /dev/null @@ -1,40 +0,0 @@ -# Research: Edit Combatant - -**Feature**: 004-edit-combatant -**Date**: 2026-03-03 - -## Research Summary - -No unknowns or NEEDS CLARIFICATION items exist in the spec or technical context. The feature follows well-established patterns already present in the codebase. - -## Decision: Domain Function Pattern - -**Decision**: Follow the identical pattern used by `addCombatant` and `removeCombatant` — pure function returning `EditCombatantSuccess | DomainError`. - -**Rationale**: Consistency with existing code. All three existing domain operations use the same signature shape `(encounter, ...args) => { encounter, events } | DomainError`. No reason to deviate. - -**Alternatives considered**: None — the pattern is well-established and fits perfectly. - -## Decision: Event Shape - -**Decision**: `CombatantUpdated` event includes `combatantId`, `oldName`, and `newName` fields. - -**Rationale**: Including both old and new name enables downstream consumers (logging, undo, UI feedback) without needing to diff state. Follows the pattern of `CombatantRemoved` which includes `name` for context. - -**Alternatives considered**: Including only `newName` — rejected because losing the old name makes undo/logging harder with no storage savings. - -## Decision: Name Validation - -**Decision**: Reuse the same validation logic as `addCombatant` (reject empty and whitespace-only strings, same error code `"invalid-name"`). - -**Rationale**: Consistent user experience. The spec explicitly states this assumption. - -**Alternatives considered**: None — spec is explicit. - -## Decision: UI Mechanism - -**Decision**: Minimal inline edit — clicking a combatant name makes it editable via an input field, confirmed on blur or Enter. - -**Rationale**: Simplest interaction that meets FR-007 without adding modals or prompts. Follows MVP baseline. - -**Alternatives considered**: Modal dialog, browser `prompt()` — both rejected as heavier than needed for MVP. diff --git a/specs/004-edit-combatant/spec.md b/specs/004-edit-combatant/spec.md deleted file mode 100644 index de27685..0000000 --- a/specs/004-edit-combatant/spec.md +++ /dev/null @@ -1,77 +0,0 @@ -# Feature Specification: Edit Combatant - -**Feature Branch**: `004-edit-combatant` -**Created**: 2026-03-03 -**Status**: Draft -**Input**: User description: "EditCombatant: allow updating a combatant's name by id in Encounter (emit CombatantUpdated, error if id not found) and wire through application + minimal UI." - -## User Scenarios & Testing *(mandatory)* - -### User Story 1 - Rename a Combatant (Priority: P1) - -A user running an encounter realizes a combatant's name is misspelled or wants to change it. They select the combatant by its identity, provide a new name, and the system updates the combatant in-place while preserving turn order and round state. - -**Why this priority**: Core feature — without the ability to rename, the entire edit-combatant feature has no value. - -**Independent Test**: Can be fully tested by creating an encounter with combatants, editing one combatant's name, and verifying the name is updated while all other encounter state remains unchanged. - -**Acceptance Scenarios**: - -1. **Given** an encounter with combatants [Alice, Bob], **When** the user updates Bob's name to "Robert", **Then** the encounter contains [Alice, Robert] and a `CombatantUpdated` event is emitted with the combatant's id, old name, and new name. -2. **Given** an encounter with combatants [Alice, Bob] where Bob is the active combatant, **When** the user updates Bob's name to "Robert", **Then** Bob remains the active combatant (active index unchanged) and the round number is preserved. - ---- - -### User Story 2 - Error Feedback on Invalid Edit (Priority: P2) - -A user attempts to edit a combatant that no longer exists (e.g., removed in another action) or provides an invalid name. The system returns a clear error without modifying the encounter. - -**Why this priority**: Error handling ensures data integrity and provides a usable experience when things go wrong. - -**Independent Test**: Can be tested by attempting to edit a non-existent combatant id and verifying an error is returned with no state change. - -**Acceptance Scenarios**: - -1. **Given** an encounter with combatants [Alice, Bob], **When** the user attempts to update a combatant with a non-existent id, **Then** the system returns a "combatant not found" error and the encounter is unchanged. -2. **Given** an encounter with combatants [Alice, Bob], **When** the user attempts to update Alice's name to an empty string, **Then** the system returns an "invalid name" error and the encounter is unchanged. -3. **Given** an encounter with combatants [Alice, Bob], **When** the user attempts to update Alice's name to a whitespace-only string, **Then** the system returns an "invalid name" error and the encounter is unchanged. - ---- - -### Edge Cases - -- What happens when the user sets a combatant's name to the same value it already has? The system treats it as a valid update — the encounter state is unchanged but a `CombatantUpdated` event is still emitted. -- What happens when the encounter has no combatants? Editing any id returns a "combatant not found" error. - -## Requirements *(mandatory)* - -### Functional Requirements - -- **FR-001**: System MUST allow updating a combatant's name by providing the combatant's id and a new name. -- **FR-002**: System MUST emit a `CombatantUpdated` event containing the combatant id, old name, and new name upon successful update. -- **FR-003**: System MUST return a "combatant not found" error when the provided id does not match any combatant in the encounter. -- **FR-004**: System MUST return an "invalid name" error when the new name is empty or whitespace-only. -- **FR-005**: System MUST preserve turn order (active index) and round number when a combatant is renamed. -- **FR-006**: System MUST preserve the combatant's position in the combatant list (no reordering). -- **FR-007**: The user interface MUST provide a way to trigger a name edit for each combatant in the encounter. - -### Key Entities - -- **Combatant**: Identified by a unique id; has a mutable name. Editing updates only the name, preserving identity and list position. -- **CombatantUpdated (event)**: Records that a combatant's name changed. Contains combatant id, old name, and new name. - -## Success Criteria *(mandatory)* - -### Measurable Outcomes - -- **SC-001**: Users can rename any combatant in the encounter in a single action. -- **SC-002**: Renaming a combatant never disrupts turn order, active combatant, or round number. -- **SC-003**: Invalid edit attempts (missing combatant, empty name) produce a clear, actionable error message with no side effects. -- **SC-004**: The combatant's updated name is immediately visible in the encounter UI after editing. - -## Assumptions - -- Name validation follows the same rules as adding a combatant (reject empty and whitespace-only names). -- No uniqueness constraint on combatant names — multiple combatants may share the same name. -- MVP baseline does not include editing other combatant attributes (e.g., initiative score, HP). Only name editing is in scope. -- MVP baseline uses inline editing (click-to-edit input field) as the name editing mechanism. More complex UX (e.g., modal dialogs, undo/redo) is not in the MVP baseline. diff --git a/specs/004-edit-combatant/tasks.md b/specs/004-edit-combatant/tasks.md deleted file mode 100644 index 999ee70..0000000 --- a/specs/004-edit-combatant/tasks.md +++ /dev/null @@ -1,147 +0,0 @@ -# Tasks: Edit Combatant - -**Input**: Design documents from `/specs/004-edit-combatant/` -**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ - -**Tests**: Tests are included as this project follows test-driven patterns established by prior features. - -**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. - -## Format: `[ID] [P?] [Story] Description` - -- **[P]**: Can run in parallel (different files, no dependencies) -- **[Story]**: Which user story this task belongs to (e.g., US1, US2) -- Include exact file paths in descriptions - -## Phase 1: Setup (Shared Infrastructure) - -**Purpose**: Add the `CombatantUpdated` event type shared by all user stories - -- [x] T001 Add `CombatantUpdated` event interface and add it to the `DomainEvent` union in `packages/domain/src/events.ts` -- [x] T002 Add `EditCombatantSuccess` interface and `editCombatant` function signature (stub returning `DomainError`) in `packages/domain/src/edit-combatant.ts` -- [x] T003 Re-export `editCombatant` and `EditCombatantSuccess` from `packages/domain/src/index.ts` - -**Checkpoint**: Domain types compile, `editCombatant` exists as a stub - ---- - -## Phase 2: User Story 1 - Rename a Combatant (Priority: P1) 🎯 MVP - -**Goal**: A user can rename an existing combatant by id. The encounter state is updated in-place with a `CombatantUpdated` event emitted. Turn order and round number are preserved. - -**Independent Test**: Create an encounter with combatants, edit one name, verify updated name + unchanged activeIndex/roundNumber + correct event. - -### Tests for User Story 1 - -> **NOTE: Write these tests FIRST, ensure they FAIL before implementation** - -- [x] T004 [US1] Write acceptance scenario tests in `packages/domain/src/__tests__/edit-combatant.test.ts`: (1) rename succeeds with correct event containing combatantId, oldName, newName; (2) activeIndex and roundNumber preserved when renaming the active combatant; (3) combatant list order preserved; (4) renaming to same name still emits event -- [x] T005 [US1] Write invariant tests in `packages/domain/src/__tests__/edit-combatant.test.ts`: (INV-1) determinism — same inputs produce same outputs; (INV-2) exactly one event emitted on success; (INV-3) original encounter is not mutated - -### Implementation for User Story 1 - -- [x] T006 [US1] Implement `editCombatant` pure function in `packages/domain/src/edit-combatant.ts` — find combatant by id, validate name, return updated encounter with mapped combatants list and `CombatantUpdated` event -- [x] T007 [US1] Create `editCombatantUseCase` in `packages/application/src/edit-combatant-use-case.ts` following the pattern in `add-combatant-use-case.ts` (get → call domain → check error → save → return events) -- [x] T008 [US1] Re-export `editCombatantUseCase` from `packages/application/src/index.ts` -- [x] T009 [US1] Add `editCombatant(id: CombatantId, newName: string)` action to `useEncounter` hook in `apps/web/src/hooks/use-encounter.ts` -- [x] T010 [US1] Add inline name editing UI for each combatant in `apps/web/src/App.tsx` — click name to edit via input field, confirm on Enter or blur - -**Checkpoint**: User Story 1 fully functional — renaming works end-to-end, all tests pass - ---- - -## Phase 3: User Story 2 - Error Feedback on Invalid Edit (Priority: P2) - -**Goal**: Invalid edit attempts (non-existent id, empty/whitespace name) return clear errors with no side effects on encounter state. - -**Independent Test**: Attempt to edit a non-existent combatant id and an empty name, verify error returned and encounter unchanged. - -### Tests for User Story 2 - -- [x] T011 [US2] Write error scenario tests in `packages/domain/src/__tests__/edit-combatant.test.ts`: (1) non-existent id returns `"combatant-not-found"` error; (2) empty name returns `"invalid-name"` error; (3) whitespace-only name returns `"invalid-name"` error; (4) empty encounter returns `"combatant-not-found"` for any id - -### Implementation for User Story 2 - -- [x] T012 [US2] Add name validation (empty/whitespace check) to `editCombatant` in `packages/domain/src/edit-combatant.ts` — return `DomainError` with code `"invalid-name"` (should already be partially covered by T006; this task ensures the guard is correct and tested) - -**Checkpoint**: Error paths fully tested, `pnpm check` passes - ---- - -## Phase 4: Polish & Cross-Cutting Concerns - -**Purpose**: Final validation across all stories - -- [x] T013 Run `pnpm check` (format + lint + typecheck + test) and fix any issues -- [x] T014 Verify layer boundaries pass (`packages/domain` has no application/web imports) - ---- - -## Dependencies & Execution Order - -### Phase Dependencies - -- **Setup (Phase 1)**: No dependencies — can start immediately -- **User Story 1 (Phase 2)**: Depends on Setup (T001–T003) -- **User Story 2 (Phase 3)**: Depends on Setup (T001–T003); can run in parallel with US1 for tests, but implementation builds on T006 -- **Polish (Phase 4)**: Depends on all user stories being complete - -### User Story Dependencies - -- **User Story 1 (P1)**: Can start after Setup — no dependencies on other stories -- **User Story 2 (P2)**: Error handling is part of the same domain function as US1; tests can be written in parallel, but implementation in T012 refines the function created in T006 - -### Within Each User Story - -- Tests MUST be written and FAIL before implementation -- Domain function before use case -- Use case before hook -- Hook before UI - -### Parallel Opportunities - -- T004 and T005 (US1 tests) target the same file — execute sequentially -- T007 and T008 (use case + export) are sequential but fast -- T011 (US2 tests) can be written in parallel with US1 implementation (T006–T010) -- T013 and T014 (polish) can run in parallel - ---- - -## Parallel Example: User Story 1 - -```bash -# Write both test groups in parallel: -Task T004: "Acceptance scenario tests in packages/domain/src/__tests__/edit-combatant.test.ts" -Task T005: "Invariant tests in packages/domain/src/__tests__/edit-combatant.test.ts" - -# Then implement sequentially (each depends on prior): -Task T006: Domain function → T007: Use case → T008: Export → T009: Hook → T010: UI -``` - ---- - -## Implementation Strategy - -### MVP First (User Story 1 Only) - -1. Complete Phase 1: Setup (T001–T003) -2. Complete Phase 2: User Story 1 (T004–T010) -3. **STOP and VALIDATE**: `pnpm check` passes, rename works in browser -4. Deploy/demo if ready - -### Full Feature - -1. Setup (T001–T003) → Foundation ready -2. User Story 1 (T004–T010) → Rename works end-to-end (MVP!) -3. User Story 2 (T011–T012) → Error handling complete -4. Polish (T013–T014) → Final validation - ---- - -## Notes - -- [P] tasks = different files, no dependencies -- [Story] label maps task to specific user story for traceability -- T004 and T005 both write to the same test file — execute sequentially -- Commit after each phase or logical group -- Stop at any checkpoint to validate story independently diff --git a/specs/005-set-initiative/checklists/requirements.md b/specs/005-set-initiative/checklists/requirements.md deleted file mode 100644 index 788ef05..0000000 --- a/specs/005-set-initiative/checklists/requirements.md +++ /dev/null @@ -1,34 +0,0 @@ -# Specification Quality Checklist: Set Initiative - -**Purpose**: Validate specification completeness and quality before proceeding to planning -**Created**: 2026-03-04 -**Feature**: [spec.md](../spec.md) - -## Content Quality - -- [x] No implementation details (languages, frameworks, APIs) -- [x] Focused on user value and business needs -- [x] Written for non-technical stakeholders -- [x] All mandatory sections completed - -## Requirement Completeness - -- [x] No [NEEDS CLARIFICATION] markers remain -- [x] Requirements are testable and unambiguous -- [x] Success criteria are measurable -- [x] Success criteria are technology-agnostic (no implementation details) -- [x] All acceptance scenarios are defined -- [x] Edge cases are identified -- [x] Scope is clearly bounded -- [x] Dependencies and assumptions identified - -## Feature Readiness - -- [x] All functional requirements have clear acceptance criteria -- [x] User scenarios cover primary flows -- [x] Feature meets measurable outcomes defined in Success Criteria -- [x] No implementation details leak into specification - -## Notes - -- All items pass validation. Spec is ready for `/speckit.clarify` or `/speckit.plan`. diff --git a/specs/005-set-initiative/contracts/domain-api.md b/specs/005-set-initiative/contracts/domain-api.md deleted file mode 100644 index 358df24..0000000 --- a/specs/005-set-initiative/contracts/domain-api.md +++ /dev/null @@ -1,57 +0,0 @@ -# Domain API Contract: Set Initiative - -## Function Signature - -``` -setInitiative(encounter, combatantId, value) → Success | DomainError -``` - -### Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| encounter | Encounter | Current encounter state | -| combatantId | CombatantId | Target combatant to update | -| value | integer or undefined | New initiative value, or undefined to clear | - -### Success Result - -| Field | Type | Description | -|-------|------|-------------| -| encounter | Encounter | New encounter with updated combatant and reordered list | -| events | DomainEvent[] | Array containing one `InitiativeSet` event | - -### Error Codes - -| Code | Condition | -|------|-----------| -| `combatant-not-found` | No combatant with the given id exists | -| `invalid-initiative` | Value is defined but not an integer | - -### Ordering Contract - -After a successful call, `encounter.combatants` is sorted such that: -1. All combatants with `initiative !== undefined` come before those with `initiative === undefined` -2. Within the "has initiative" group: sorted descending by initiative value -3. Within the "no initiative" group: original relative order preserved -4. Equal initiative values: original relative order preserved (stable sort) - -### Active Turn Contract - -The combatant who was active before the call remains active after: -- `encounter.activeIndex` points to the same combatant (by identity) in the new order -- This holds even if the active combatant's own initiative changes - -### Invariants Preserved - -- INV-1: Empty encounters remain valid (0 combatants allowed) -- INV-2: `activeIndex` remains in bounds after reorder -- INV-3: `roundNumber` is never changed by `setInitiative` - -## Use Case Signature - -``` -setInitiativeUseCase(store, combatantId, value) → DomainEvent[] | DomainError -``` - -Follows the standard use case pattern: get encounter from store, call domain function, save on success, return events or error. diff --git a/specs/005-set-initiative/data-model.md b/specs/005-set-initiative/data-model.md deleted file mode 100644 index c592fc0..0000000 --- a/specs/005-set-initiative/data-model.md +++ /dev/null @@ -1,63 +0,0 @@ -# Data Model: Set Initiative - -## Entity Changes - -### Combatant (modified) - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| id | CombatantId (branded string) | Yes | Unique identifier | -| name | string | Yes | Display name (non-empty, trimmed) | -| initiative | integer | No | Initiative value for turn ordering. Unset means "not yet rolled." | - -**Validation rules**: -- `initiative` must be an integer when set (no floats, NaN, or Infinity) -- Zero and negative integers are valid -- Unset (`undefined`) is valid — combatant has not rolled initiative yet - -### Encounter (unchanged structure, new ordering behavior) - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| combatants | readonly Combatant[] | Yes | Ordered list. Now sorted by initiative descending (unset last, stable sort for ties). | -| activeIndex | number | Yes | Index of the active combatant. Adjusted to follow the active combatant's identity through reorders. | -| roundNumber | number | Yes | Current round (≥ 1). Unchanged by initiative operations. | - -**Ordering invariant**: After any `setInitiative` call, `combatants` is sorted such that: -1. Combatants with initiative come first, ordered highest to lowest -2. Combatants without initiative come last -3. Ties within each group preserve relative insertion order (stable sort) - -## New Domain Event - -### InitiativeSet - -Emitted when a combatant's initiative value is set, changed, or cleared. - -| Field | Type | Description | -|-------|------|-------------| -| type | "InitiativeSet" | Event discriminant | -| combatantId | CombatantId | The combatant whose initiative changed | -| previousValue | integer or undefined | The initiative value before the change | -| newValue | integer or undefined | The initiative value after the change | - -## State Transitions - -### setInitiative(encounter, combatantId, value) - -**Input**: Current encounter, target combatant id, new initiative value (integer or undefined to clear) - -**Output**: Updated encounter with reordered combatants and adjusted activeIndex, plus events - -**Error conditions**: -- `combatant-not-found`: No combatant with the given id exists in the encounter -- `invalid-initiative`: Value is not an integer (when defined) - -**Transition logic**: -1. Find target combatant by id → error if not found -2. Validate value is integer (when defined) → error if invalid -3. Record the active combatant's id (for preservation) -4. Update the target combatant's initiative value -5. Stable-sort combatants: initiative descending, unset last -6. Find the active combatant's new index in the sorted array -7. Return new encounter + `InitiativeSet` event diff --git a/specs/005-set-initiative/plan.md b/specs/005-set-initiative/plan.md deleted file mode 100644 index 960e072..0000000 --- a/specs/005-set-initiative/plan.md +++ /dev/null @@ -1,83 +0,0 @@ -# Implementation Plan: Set Initiative - -**Branch**: `005-set-initiative` | **Date**: 2026-03-04 | **Spec**: [spec.md](./spec.md) -**Input**: Feature specification from `/specs/005-set-initiative/spec.md` - -## Summary - -Add an optional integer initiative property to combatants and a `setInitiative` domain function that sets/changes/clears the value and automatically reorders combatants descending by initiative (unset last, stable sort for ties). The active combatant's turn is preserved through reorders by tracking identity rather than position. - -## Technical Context - -**Language/Version**: TypeScript 5.x (strict mode, verbatimModuleSyntax) -**Primary Dependencies**: React 19, Vite -**Storage**: In-memory React state (local-first, single-user MVP) -**Testing**: Vitest -**Target Platform**: Web browser (localhost:5173 dev) -**Project Type**: Web application (monorepo: domain → application → web adapter) -**Performance Goals**: N/A (local in-memory, trivial data sizes) -**Constraints**: Pure domain functions, no I/O in domain layer -**Scale/Scope**: Single-user, single encounter - -## Constitution Check - -*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* - -| Principle | Status | Notes | -|-----------|--------|-------| -| I. Deterministic Domain Core | PASS | `setInitiative` is a pure function: same encounter + id + value → same result. No I/O, randomness, or clocks. | -| II. Layered Architecture | PASS | Domain function in `packages/domain`, use case in `packages/application`, UI in `apps/web`. Dependency direction preserved. | -| III. Agent Boundary | N/A | No agent layer involvement in this feature. | -| IV. Clarification-First | PASS | Spec has no NEEDS CLARIFICATION markers. All design decisions are spec-driven. | -| V. Escalation Gates | PASS | Feature scope matches spec exactly. No out-of-scope additions. | -| VI. MVP Baseline Language | PASS | Spec uses "MVP baseline does not include" for secondary tiebreakers. | -| VII. No Gameplay Rules | PASS | Constitution contains no gameplay mechanics; initiative logic is in the spec. | - -All gates pass. No violations to justify. - -**Post-Design Re-check**: All gates still pass. The `setInitiative` domain function is pure, layering is preserved, and no out-of-scope additions were introduced during design. - -## Project Structure - -### Documentation (this feature) - -```text -specs/005-set-initiative/ -├── spec.md -├── plan.md # This file -├── research.md # Phase 0 output -├── data-model.md # Phase 1 output -├── quickstart.md # Phase 1 output -├── contracts/ # Phase 1 output -│ └── domain-api.md -├── checklists/ -│ └── requirements.md -└── tasks.md # Phase 2 output (via /speckit.tasks) -``` - -### Source Code (repository root) - -```text -packages/domain/src/ -├── types.ts # Modified: add initiative to Combatant -├── events.ts # Modified: add InitiativeSet event -├── set-initiative.ts # New: setInitiative domain function -├── index.ts # Modified: export setInitiative -└── __tests__/ - └── set-initiative.test.ts # New: tests for setInitiative - -packages/application/src/ -├── set-initiative-use-case.ts # New: setInitiativeUseCase -└── index.ts # Modified: export use case - -apps/web/src/ -├── hooks/ -│ └── use-encounter.ts # Modified: add setInitiative callback -└── App.tsx # Modified: add initiative input field -``` - -**Structure Decision**: Follows existing monorepo layered structure. Each new domain operation gets its own file per established convention. - -## Complexity Tracking - -No constitution violations. Table not needed. diff --git a/specs/005-set-initiative/quickstart.md b/specs/005-set-initiative/quickstart.md deleted file mode 100644 index f205e4a..0000000 --- a/specs/005-set-initiative/quickstart.md +++ /dev/null @@ -1,36 +0,0 @@ -# Quickstart: Set Initiative - -## What This Feature Does - -Adds an optional initiative value to combatants. When set, the encounter automatically sorts combatants from highest to lowest initiative. Combatants without initiative appear at the end. The active turn is preserved through reorders. - -## Key Files to Modify - -1. **`packages/domain/src/types.ts`** — Add `initiative?: number` to `Combatant` -2. **`packages/domain/src/events.ts`** — Add `InitiativeSet` event to the union -3. **`packages/domain/src/set-initiative.ts`** — New domain function (pure, no I/O) -4. **`packages/domain/src/index.ts`** — Export new function and types -5. **`packages/application/src/set-initiative-use-case.ts`** — New use case -6. **`packages/application/src/index.ts`** — Export use case -7. **`apps/web/src/hooks/use-encounter.ts`** — Add `setInitiative` callback -8. **`apps/web/src/App.tsx`** — Add initiative input next to each combatant - -## Implementation Order - -1. Domain types + event (foundation) -2. Domain function + tests (core logic) -3. Application use case (orchestration) -4. Web adapter hook + UI (user-facing) - -## How to Verify - -```bash -pnpm check # Must pass: format + lint + typecheck + test -``` - -## Patterns to Follow - -- Domain functions return `{ encounter, events } | DomainError` — never throw -- Use `readonly` everywhere, create new objects via spread -- Tests live in `packages/domain/src/__tests__/` -- Use cases follow get → call → check error → save → return events diff --git a/specs/005-set-initiative/research.md b/specs/005-set-initiative/research.md deleted file mode 100644 index e8a425d..0000000 --- a/specs/005-set-initiative/research.md +++ /dev/null @@ -1,49 +0,0 @@ -# Research: Set Initiative - -## R-001: Stable Sort for Initiative Ordering - -**Decision**: Use JavaScript's built-in `Array.prototype.sort()` which is guaranteed stable (ES2019+). Combatants with equal initiative retain their relative order from the original array. - -**Rationale**: All modern browsers and Node.js engines implement stable sort. No external library needed. The existing codebase already relies on insertion-order preservation in array operations. - -**Alternatives considered**: -- Custom merge sort implementation — unnecessary since native sort is stable. -- Separate "sort key" field — over-engineering for the current requirement. - -## R-002: Active Turn Preservation Through Reorder - -**Decision**: After sorting, find the new index of the combatant who was active before the sort (by `CombatantId` identity). Update `activeIndex` to point to that combatant's new position. - -**Rationale**: The existing `removeCombatant` function already demonstrates the pattern of adjusting `activeIndex` to track a specific combatant through array mutations. This approach is simpler than alternatives since we can look up the active combatant's id before sorting, then find its new index after sorting. - -**Alternatives considered**: -- Store active combatant as `activeCombatantId` instead of `activeIndex` — would require changing the `Encounter` type and all downstream consumers. Too broad for this feature. -- Compute a position delta — fragile and error-prone with stable sort edge cases. - -## R-003: Initiative as Optional Property on Combatant - -**Decision**: Add `readonly initiative?: number` to the `Combatant` interface. `undefined` means "not yet set." - -**Rationale**: Matches the spec requirement for combatants without initiative (FR-005). Using `undefined` (optional property) rather than `null` aligns with TypeScript conventions and the existing codebase style (no `null` usage in domain types). - -**Alternatives considered**: -- Separate `InitiativeMap` keyed by `CombatantId` — breaks co-location, complicates sorting, doesn't match the existing pattern where combatant data lives on the `Combatant` type. -- `number | null` — adds a second "empty" representation alongside `undefined`; the codebase has no precedent for `null` in domain types. - -## R-004: Clearing Initiative - -**Decision**: Clearing initiative means setting it to `undefined`. The `setInitiative` function accepts `number | undefined` as the value parameter. When `undefined`, the combatant moves to the end of the order (per FR-003, FR-005). - -**Rationale**: Reuses the same function for set, change, and clear operations. Keeps the API surface minimal. - -**Alternatives considered**: -- Separate `clearInitiative` function — unnecessary given the value can simply be `undefined`. - -## R-005: Integer Validation - -**Decision**: Validate that the initiative value is a safe integer using `Number.isInteger()`. Reject `NaN`, `Infinity`, and floating-point values. Accept zero and negative integers (per FR-009). - -**Rationale**: `Number.isInteger()` handles all edge cases: returns false for `NaN`, `Infinity`, `-Infinity`, and non-integer numbers. Allows the full range of safe integers. - -**Alternatives considered**: -- Branded `Initiative` type — adds type complexity without significant safety benefit since validation happens at the domain boundary. diff --git a/specs/005-set-initiative/spec.md b/specs/005-set-initiative/spec.md deleted file mode 100644 index 027870f..0000000 --- a/specs/005-set-initiative/spec.md +++ /dev/null @@ -1,116 +0,0 @@ -# Feature Specification: Set Initiative - -**Feature Branch**: `005-set-initiative` -**Created**: 2026-03-04 -**Status**: Draft -**Input**: User description: "Allow setting an initiative value for combatants; when initiative is set or changed, the encounter automatically orders combatants so the highest initiative acts first." - -## User Scenarios & Testing *(mandatory)* - -### User Story 1 - Set Initiative for a Combatant (Priority: P1) - -As a game master running an encounter, I want to assign an initiative value to a combatant so that the encounter's turn order reflects each combatant's rolled initiative. - -**Why this priority**: Initiative values are the core of this feature — without them, automatic ordering cannot happen. - -**Independent Test**: Can be fully tested by setting an initiative value on a combatant and verifying the value is stored and the combatant list is reordered accordingly. - -**Acceptance Scenarios**: - -1. **Given** an encounter with combatant "Goblin" (no initiative set), **When** the user sets initiative to 15 for "Goblin", **Then** "Goblin" has initiative value 15. -2. **Given** an encounter with combatant "Goblin" (initiative 15), **When** the user changes initiative to 8, **Then** "Goblin" has initiative value 8. -3. **Given** an encounter with combatant "Goblin" (no initiative set), **When** the user attempts to set a non-integer initiative value, **Then** the system rejects the input and the combatant's initiative remains unset. -4. **Given** an encounter with combatant "Goblin" (initiative 15), **When** the user clears "Goblin"'s initiative, **Then** "Goblin"'s initiative is unset and "Goblin" moves to the end of the turn order. - ---- - -### User Story 2 - Automatic Ordering by Initiative (Priority: P1) - -As a game master, I want the encounter to automatically sort combatants from highest to lowest initiative so I don't have to manually reorder them. - -**Why this priority**: Automatic ordering is the primary value of initiative — it directly determines turn order. - -**Independent Test**: Can be fully tested by setting initiative values on multiple combatants and verifying the combatant list is sorted highest-first. - -**Acceptance Scenarios**: - -1. **Given** combatants A (initiative 20), B (initiative 5), C (initiative 15), **When** all initiatives are set, **Then** the combatant order is A (20), C (15), B (5). -2. **Given** combatants in order A (20), C (15), B (5), **When** B's initiative is changed to 25, **Then** the order becomes B (25), A (20), C (15). -3. **Given** combatants A (initiative 10) and B (initiative 10) with the same value, **Then** their relative order is preserved (stable sort — the combatant who was added or set first stays ahead). - ---- - -### User Story 3 - Combatants Without Initiative (Priority: P2) - -As a game master, I want combatants who haven't had their initiative set yet to appear at the end of the turn order so that the encounter remains usable while I'm still entering initiative values. - -**Why this priority**: This supports the practical workflow of entering initiatives one at a time as players roll. - -**Independent Test**: Can be fully tested by having a mix of combatants with and without initiative values and verifying ordering. - -**Acceptance Scenarios**: - -1. **Given** combatants A (initiative 15), B (no initiative), C (initiative 10), **Then** the order is A (15), C (10), B (no initiative). -2. **Given** combatants A (no initiative) and B (no initiative), **Then** their relative order is preserved from when they were added. -3. **Given** combatant A (no initiative), **When** initiative is set to 12, **Then** A moves to its correct sorted position among combatants that have initiative values. - ---- - -### User Story 4 - Active Turn Preservation During Reorder (Priority: P2) - -As a game master mid-encounter, I want the active combatant's turn to be preserved when initiative changes cause a reorder so that I don't lose track of whose turn it is. - -**Why this priority**: Changing initiative mid-encounter (e.g., due to a delayed action or correction) must not disrupt the current turn. - -**Independent Test**: Can be fully tested by setting the active combatant, changing another combatant's initiative, and verifying the active turn still points to the same combatant. - -**Acceptance Scenarios**: - -1. **Given** it is combatant B's turn (activeIndex points to B), **When** combatant A's initiative is changed causing a reorder, **Then** the active turn still points to combatant B. -2. **Given** it is combatant A's turn, **When** combatant A's own initiative is changed causing a reorder, **Then** the active turn still points to combatant A. - ---- - -### Edge Cases - -- What happens when a combatant is added without initiative during an ongoing encounter? They appear at the end of the order. -- What happens when all combatants have the same initiative value? Their relative order is preserved (insertion order). -- What happens when initiative is set to zero? Zero is a valid initiative value and is treated normally in sorting. -- What happens when initiative is set to a negative number? Negative values are valid initiative values (some game systems use them). -- What happens when initiative is removed/cleared from a combatant? The combatant moves to the end of the order (treated as unset). - -## Requirements *(mandatory)* - -### Functional Requirements - -- **FR-001**: System MUST allow setting an integer initiative value for any combatant in an encounter. -- **FR-002**: System MUST allow changing an existing initiative value for a combatant. -- **FR-003**: System MUST allow clearing a combatant's initiative value (returning it to unset). -- **FR-004**: System MUST automatically reorder combatants from highest to lowest initiative whenever an initiative value is set, changed, or cleared. -- **FR-005**: System MUST place combatants without an initiative value after all combatants that have initiative values. -- **FR-006**: System MUST use a stable sort so that combatants with equal initiative (or multiple combatants without initiative) retain their relative order. -- **FR-007**: System MUST preserve the active combatant's turn when reordering occurs — the active turn tracks the combatant identity, not the position. -- **FR-008**: System MUST reject non-integer initiative values and return an error. -- **FR-009**: System MUST accept zero and negative integers as valid initiative values. -- **FR-010**: System MUST emit a domain event when a combatant's initiative is set or changed. - -### Key Entities - -- **Combatant**: Gains an optional initiative property (integer or unset). When set, determines the combatant's position in the encounter's turn order. -- **Encounter**: Combatant ordering becomes initiative-driven. The `activeIndex` must track the active combatant's identity through reorders. - -## Success Criteria *(mandatory)* - -### Measurable Outcomes - -- **SC-001**: Users can set initiative for any combatant in a single action. -- **SC-002**: After setting or changing any initiative value, the encounter's combatant order immediately reflects the correct descending initiative sort. -- **SC-003**: The active combatant's turn is never lost or shifted to a different combatant due to an initiative-driven reorder. -- **SC-004**: Combatants without initiative are always displayed after combatants with initiative values. - -## Assumptions - -- Initiative values are integers (no decimals). This matches common tabletop RPG conventions. -- There is no initiative "roll" or randomization in the domain — the user provides the final initiative value. Dice rolling is outside scope. -- Tiebreaking for equal initiative values uses stable sort (preserves existing relative order). MVP baseline does not include secondary tiebreakers (e.g., Dexterity modifier). -- Clearing initiative is supported to allow corrections (e.g., a combatant hasn't rolled yet). diff --git a/specs/005-set-initiative/tasks.md b/specs/005-set-initiative/tasks.md deleted file mode 100644 index 7cbc73d..0000000 --- a/specs/005-set-initiative/tasks.md +++ /dev/null @@ -1,179 +0,0 @@ -# Tasks: Set Initiative - -**Input**: Design documents from `/specs/005-set-initiative/` -**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/domain-api.md, quickstart.md - -**Tests**: Tests are included as this project follows TDD conventions (test files exist for all domain functions). - -**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. - -## Format: `[ID] [P?] [Story] Description` - -- **[P]**: Can run in parallel (different files, no dependencies) -- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) -- Include exact file paths in descriptions - ---- - -## Phase 1: Setup (Shared Infrastructure) - -**Purpose**: No new project setup needed — existing monorepo. This phase covers foundational type and event changes shared across all user stories. - -- [x] T001 Add optional `initiative` property to `Combatant` interface in `packages/domain/src/types.ts` -- [x] T002 Add `InitiativeSet` event type (with `combatantId`, `previousValue`, `newValue` fields) to `DomainEvent` union in `packages/domain/src/events.ts` - -**Checkpoint**: Types compile, existing tests still pass (`pnpm check`) - ---- - -## Phase 2: User Story 1 + User Story 2 — Set Initiative & Automatic Ordering (Priority: P1) MVP - -**Goal**: Users can set/change/clear initiative values on combatants, and the encounter automatically reorders combatants from highest to lowest initiative. - -**Independent Test**: Set initiative on multiple combatants and verify the combatant list is sorted descending by initiative value. - -### Tests for User Stories 1 & 2 - -> **NOTE: Write these tests FIRST, ensure they FAIL before implementation** - -- [x] T003 [US1] Write acceptance tests for setting initiative (set, change, reject non-integer) in `packages/domain/src/__tests__/set-initiative.test.ts` -- [x] T004 [US2] Write acceptance tests for automatic ordering (descending sort, stable sort for ties, reorder on change) in `packages/domain/src/__tests__/set-initiative.test.ts` -- [x] T005 Write invariant tests (determinism, immutability, event shape, roundNumber unchanged) in `packages/domain/src/__tests__/set-initiative.test.ts` - -### Implementation for User Stories 1 & 2 - -- [x] T006 [US1] [US2] Implement `setInitiative(encounter, combatantId, value)` domain function in `packages/domain/src/set-initiative.ts` — validate combatant exists, validate integer, update initiative, stable-sort descending, emit `InitiativeSet` event -- [x] T007 Export `setInitiative` and related types from `packages/domain/src/index.ts` -- [x] T008 Implement `setInitiativeUseCase(store, combatantId, value)` in `packages/application/src/set-initiative-use-case.ts` following existing use case pattern (get → call → check error → save → return events) -- [x] T009 Export `setInitiativeUseCase` from `packages/application/src/index.ts` - -**Checkpoint**: Domain tests pass, `pnpm check` passes. Core initiative logic is complete. - ---- - -## Phase 3: User Story 3 — Combatants Without Initiative (Priority: P2) - -**Goal**: Combatants without initiative appear after all combatants with initiative, preserving their relative order. - -**Independent Test**: Create a mix of combatants with and without initiative and verify ordering (initiative-set first descending, then unset in insertion order). - -### Tests for User Story 3 - -- [x] T010 [US3] Write acceptance tests for unset-initiative ordering (unset after set, multiple unset preserve order, setting initiative moves combatant up) in `packages/domain/src/__tests__/set-initiative.test.ts` - -### Implementation for User Story 3 - -- [x] T011 [US3] Verify that sort logic in `packages/domain/src/set-initiative.ts` already handles `undefined` initiative correctly (combatants without initiative sort after those with initiative, stable sort within each group) — add handling if not already present in T006 - -**Checkpoint**: All ordering scenarios pass including mixed set/unset combatants. - ---- - -## Phase 4: User Story 4 — Active Turn Preservation During Reorder (Priority: P2) - -**Goal**: The active combatant's turn is preserved when initiative changes cause the combatant list to be reordered. - -**Independent Test**: Set active combatant, change another combatant's initiative causing reorder, verify active turn still points to the same combatant. - -### Tests for User Story 4 - -- [x] T012 [US4] Write acceptance tests for active turn preservation (reorder doesn't shift active turn, active combatant's own initiative change preserves turn) in `packages/domain/src/__tests__/set-initiative.test.ts` - -### Implementation for User Story 4 - -- [x] T013 [US4] Verify that `activeIndex` identity-tracking in `packages/domain/src/set-initiative.ts` works correctly when reordering occurs — the logic (record active id before sort, find new index after sort) should already exist from T006; add or fix if needed - -**Checkpoint**: Active turn is preserved through all reorder scenarios. `pnpm check` passes. - ---- - -## Phase 5: Web Adapter Integration - -**Purpose**: Wire initiative into the React UI so users can actually set initiative values. - -- [x] T014 Add `setInitiative` callback to `useEncounter` hook in `apps/web/src/hooks/use-encounter.ts` — call `setInitiativeUseCase`, handle errors, append events -- [x] T015 Add initiative input field next to each combatant in `apps/web/src/App.tsx` — numeric input, display current value, clear button, call `setInitiative` on change - -**Checkpoint**: Full feature works end-to-end in the browser. `pnpm check` passes. - ---- - -## Phase 6: Polish & Cross-Cutting Concerns - -**Purpose**: Edge case coverage and final validation. - -- [x] T016 Write edge case tests (zero initiative, negative initiative, clearing initiative, all same value) in `packages/domain/src/__tests__/set-initiative.test.ts` -- [x] T017 Run `pnpm check` (format + lint + typecheck + test) and fix any issues -- [x] T018 Verify layer boundary compliance (domain imports no framework/adapter code) - ---- - -## Dependencies & Execution Order - -### Phase Dependencies - -- **Phase 1 (Setup)**: No dependencies — types and events first -- **Phase 2 (US1+US2 MVP)**: Depends on Phase 1 -- **Phase 3 (US3)**: Depends on Phase 2 (extends sort logic) -- **Phase 4 (US4)**: Depends on Phase 2 (extends activeIndex logic) -- **Phase 5 (Web Adapter)**: Depends on Phases 2–4 (needs complete domain + application layer) -- **Phase 6 (Polish)**: Depends on all previous phases - -### User Story Dependencies - -- **US1 + US2 (P1)**: Combined because sorting is inherent to setting initiative — they share the same domain function -- **US3 (P2)**: Extends the sort logic from US1+US2 to handle `undefined`. Can be developed immediately after Phase 2. -- **US4 (P2)**: Extends the `activeIndex` logic from US1+US2. Can be developed in parallel with US3. - -### Parallel Opportunities - -- **T001 and T002** can run in parallel (different files) -- **T003, T004, T005** can run in parallel (same file but different test groups — practically written together) -- **US3 (Phase 3) and US4 (Phase 4)** can run in parallel after Phase 2 -- **T014 and T015** can run in parallel (different files) - ---- - -## Parallel Example: Phase 2 (MVP) - -```bash -# Tests first (all in same file, written together): -T003: Acceptance tests for setting initiative -T004: Acceptance tests for automatic ordering -T005: Invariant tests - -# Then implementation: -T006: Domain function (core logic) -T007: Domain exports -T008: Application use case (after T006-T007) -T009: Application exports -``` - ---- - -## Implementation Strategy - -### MVP First (User Stories 1 + 2) - -1. Complete Phase 1: Type + event changes -2. Complete Phase 2: Domain function + use case with tests -3. **STOP and VALIDATE**: `pnpm check` passes, initiative setting and ordering works -4. Optionally wire up UI (Phase 5) for a minimal demo - -### Incremental Delivery - -1. Phase 1 → Types ready -2. Phase 2 → MVP: set initiative + auto-ordering works -3. Phase 3 → Unset combatants handled correctly -4. Phase 4 → Active turn preserved through reorders -5. Phase 5 → UI wired up, feature usable in browser -6. Phase 6 → Edge cases covered, quality verified - ---- - -## Notes - -- US1 and US2 are combined in Phase 2 because the domain function `setInitiative` inherently performs both setting and sorting — they cannot be meaningfully separated -- US3 and US4 are separable extensions of the sort and activeIndex logic respectively -- All domain tests follow existing patterns: helper functions for test data, acceptance scenarios mapped from spec, invariant tests for determinism/immutability -- Commit after each phase checkpoint diff --git a/specs/006-pre-commit-gate/checklists/requirements.md b/specs/006-pre-commit-gate/checklists/requirements.md deleted file mode 100644 index df553d1..0000000 --- a/specs/006-pre-commit-gate/checklists/requirements.md +++ /dev/null @@ -1,34 +0,0 @@ -# Specification Quality Checklist: Pre-Commit Gate - -**Purpose**: Validate specification completeness and quality before proceeding to planning -**Created**: 2026-03-05 -**Feature**: [spec.md](../spec.md) - -## Content Quality - -- [x] No implementation details (languages, frameworks, APIs) -- [x] Focused on user value and business needs -- [x] Written for non-technical stakeholders -- [x] All mandatory sections completed - -## Requirement Completeness - -- [x] No [NEEDS CLARIFICATION] markers remain -- [x] Requirements are testable and unambiguous -- [x] Success criteria are measurable -- [x] Success criteria are technology-agnostic (no implementation details) -- [x] All acceptance scenarios are defined -- [x] Edge cases are identified -- [x] Scope is clearly bounded -- [x] Dependencies and assumptions identified - -## Feature Readiness - -- [x] All functional requirements have clear acceptance criteria -- [x] User scenarios cover primary flows -- [x] Feature meets measurable outcomes defined in Success Criteria -- [x] No implementation details leak into specification - -## Notes - -- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`. diff --git a/specs/006-pre-commit-gate/plan.md b/specs/006-pre-commit-gate/plan.md deleted file mode 100644 index 8e1df32..0000000 --- a/specs/006-pre-commit-gate/plan.md +++ /dev/null @@ -1,67 +0,0 @@ -# Implementation Plan: Pre-Commit Gate - -**Branch**: `006-pre-commit-gate` | **Date**: 2026-03-05 | **Spec**: [spec.md](./spec.md) -**Input**: Feature specification from `/specs/006-pre-commit-gate/spec.md` - -## Summary - -Enforce a pre-commit quality gate by adding Lefthook as a Git hooks manager. A `lefthook.yml` configuration defines a pre-commit hook that runs `pnpm check`. The hook auto-installs via a `prepare` script in `package.json`, ensuring zero manual setup for developers after `pnpm install`. - -## Technical Context - -**Language/Version**: TypeScript 5.x (project), Go binary via npm (Lefthook) -**Primary Dependencies**: `lefthook` (npm devDependency) -**Storage**: N/A -**Testing**: Manual verification (commit with passing/failing checks) -**Target Platform**: macOS, Linux (developer workstations) -**Project Type**: Monorepo (pnpm workspaces) -- web application with domain/application/web layers -**Performance Goals**: No additional overhead beyond `pnpm check` execution time -**Constraints**: Must auto-install on `pnpm install`; must not interfere with CI -**Scale/Scope**: 2 files changed (lefthook.yml created, package.json modified) - -## Constitution Check - -*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* - -| Principle | Status | Notes | -|-----------|--------|-------| -| I. Deterministic Domain Core | N/A | No domain logic changes | -| II. Layered Architecture | PASS | Tooling-only change, no layer imports affected | -| III. Agent Boundary | N/A | No agent layer changes | -| IV. Clarification-First | PASS | User explicitly specified Lefthook; no ambiguity | -| V. Escalation Gates | PASS | Feature is within spec scope | -| VI. MVP Baseline Language | PASS | No permanent bans introduced | -| VII. No Gameplay Rules | N/A | Not a gameplay feature | -| Development Workflow (merge gate) | PASS | Directly implements the "automated checks must pass" rule | -| Layer boundary compliance | N/A | No source code layer changes | - -**Post-design re-check**: All gates still pass. No design decisions introduced new violations. - -## Project Structure - -### Documentation (this feature) - -```text -specs/006-pre-commit-gate/ -├── plan.md # This file -├── research.md # Phase 0 output -├── quickstart.md # Phase 1 output -├── spec.md # Feature specification -└── checklists/ - └── requirements.md # Spec quality checklist -``` - -### Source Code (repository root) - -```text -./ -├── lefthook.yml # NEW — Lefthook configuration (pre-commit hook) -├── package.json # MODIFIED — add lefthook devDep + prepare script -└── (all existing files unchanged) -``` - -**Structure Decision**: This feature only adds tooling configuration at the repository root. No source code directories are created or modified. The existing monorepo structure (`packages/*`, `apps/*`) is unchanged. - -## Complexity Tracking - -No constitution violations. Table not applicable. diff --git a/specs/006-pre-commit-gate/quickstart.md b/specs/006-pre-commit-gate/quickstart.md deleted file mode 100644 index 6037962..0000000 --- a/specs/006-pre-commit-gate/quickstart.md +++ /dev/null @@ -1,34 +0,0 @@ -# Quickstart: Pre-Commit Gate - -## What This Feature Does - -Adds a Git pre-commit hook (managed by Lefthook) that runs `pnpm check` before every commit. If any check fails, the commit is blocked. - -## Files to Create/Modify - -| File | Action | Purpose | -|------|--------|---------| -| `lefthook.yml` | Create | Lefthook configuration with pre-commit hook | -| `package.json` | Modify | Add `lefthook` devDependency + `prepare` script | - -## Setup After Implementation - -After the feature is implemented, hooks activate automatically: - -```bash -pnpm install # installs lefthook + runs `prepare` which calls `lefthook install` -``` - -## How It Works - -1. Developer runs `git commit` -2. Lefthook intercepts via the Git pre-commit hook -3. Lefthook runs `pnpm check` (format + lint + typecheck + test) -4. If `pnpm check` exits 0 → commit proceeds -5. If `pnpm check` exits non-zero → commit is blocked, output shown - -## Bypass - -```bash -git commit --no-verify # skips the pre-commit hook -``` diff --git a/specs/006-pre-commit-gate/research.md b/specs/006-pre-commit-gate/research.md deleted file mode 100644 index e45ec85..0000000 --- a/specs/006-pre-commit-gate/research.md +++ /dev/null @@ -1,45 +0,0 @@ -# Research: Pre-Commit Gate - -## R1: Hook Management Tool - -**Decision**: Use [Lefthook](https://github.com/evilmartians/lefthook) (npm package) as the Git hooks manager. - -**Rationale**: -- User explicitly requested Lefthook. -- Lightweight, standalone Go binary distributed via npm -- no runtime dependencies. -- Simple YAML configuration (`lefthook.yml`). -- Auto-installs hooks via npm `postinstall` lifecycle script -- satisfies FR-005 (no manual setup). -- Well-maintained, widely adopted (used by n8n, Shopify, and others). -- Respects `--no-verify` by default (standard Git behavior) -- satisfies FR-006. - -**Alternatives considered**: -- Husky: Popular but heavier configuration, requires `.husky/` directory with shell scripts. -- `core.hooksPath`: Native Git, but requires manual setup or custom scripts for auto-install. -- Simple `prepare` script copying a shell script: Works but no parallel jobs, no structured config. - -## R2: Auto-Install Mechanism - -**Decision**: Use a `prepare` script in root `package.json` that runs `lefthook install`. - -**Rationale**: -- The `prepare` lifecycle hook runs automatically after `pnpm install`. -- This ensures every developer gets hooks installed without extra steps after cloning. -- Lefthook's npm package includes a `postinstall` script that can auto-install, but an explicit `prepare` script is more transparent and reliable across package managers. -- In CI environments, `CI=true` prevents the `prepare` script from running (standard npm/pnpm behavior), avoiding unnecessary hook installation in CI. - -**Alternatives considered**: -- Relying solely on lefthook's built-in `postinstall`: Less transparent; behavior varies with `CI` env var. -- Manual `lefthook install` step in README: Violates FR-005. - -## R3: Hook Command Strategy - -**Decision**: The pre-commit hook runs `pnpm check` as a single command. - -**Rationale**: -- `pnpm check` already orchestrates format, lint, typecheck, and test in sequence. -- Running it as one command keeps the hook configuration minimal and consistent with the existing merge-gate workflow. -- Output from `pnpm check` already identifies which specific check failed (FR-004, SC-004). - -**Alternatives considered**: -- Running each check as a separate Lefthook job with `parallel: true`: Could be faster but adds configuration complexity and the existing `pnpm check` script already handles sequencing. MVP baseline does not include parallel hook jobs. -- Using `{staged_files}` for file-scoped checks: MVP baseline does not include staged-only checking per spec assumptions. diff --git a/specs/006-pre-commit-gate/spec.md b/specs/006-pre-commit-gate/spec.md deleted file mode 100644 index a5d8f76..0000000 --- a/specs/006-pre-commit-gate/spec.md +++ /dev/null @@ -1,88 +0,0 @@ -# Feature Specification: Pre-Commit Gate - -**Feature Branch**: `006-pre-commit-gate` -**Created**: 2026-03-05 -**Status**: Draft -**Input**: User description: "Enforce a pre-commit gate: block commits unless `pnpm check` passes." - -## User Scenarios & Testing *(mandatory)* - -### User Story 1 - Blocked Commit on Failing Checks (Priority: P1) - -A developer attempts to commit code that does not pass `pnpm check` (format, lint, typecheck, or test failures). The commit is automatically rejected with a clear message indicating what failed, preventing broken code from entering the repository. - -**Why this priority**: This is the core purpose of the feature -- preventing commits that violate project quality standards. - -**Independent Test**: Can be tested by introducing a deliberate lint or type error, attempting to commit, and verifying the commit is blocked with an informative error message. - -**Acceptance Scenarios**: - -1. **Given** a developer has staged changes that fail formatting, **When** they run `git commit`, **Then** the commit is rejected and the output shows the formatting errors. -2. **Given** a developer has staged changes that fail linting, **When** they run `git commit`, **Then** the commit is rejected and the output shows the lint errors. -3. **Given** a developer has staged changes that fail typechecking, **When** they run `git commit`, **Then** the commit is rejected and the output shows the typecheck errors. -4. **Given** a developer has staged changes that fail tests, **When** they run `git commit`, **Then** the commit is rejected and the output shows the test failures. - ---- - -### User Story 2 - Successful Commit on Passing Checks (Priority: P1) - -A developer commits code that passes all checks. The pre-commit gate runs `pnpm check`, all checks pass, and the commit proceeds normally without extra friction. - -**Why this priority**: Equally critical -- the gate must not block valid commits. A gate that only blocks but never allows is useless. - -**Independent Test**: Can be tested by making a valid code change, committing, and verifying the commit succeeds after checks pass. - -**Acceptance Scenarios**: - -1. **Given** a developer has staged changes that pass all checks, **When** they run `git commit`, **Then** `pnpm check` runs and the commit completes successfully. - ---- - -### User Story 3 - Bypass Gate in Emergencies (Priority: P2) - -A developer needs to bypass the pre-commit gate in an emergency situation (e.g., a hotfix where the existing codebase already has a known issue). They can use the standard Git `--no-verify` flag to skip the hook. - -**Why this priority**: Important escape hatch, but not the primary use case. Standard Git behavior should be preserved. - -**Independent Test**: Can be tested by attempting `git commit --no-verify` with failing checks and verifying the commit succeeds. - -**Acceptance Scenarios**: - -1. **Given** a developer has staged changes that fail checks, **When** they run `git commit --no-verify`, **Then** the commit proceeds without running the pre-commit gate. - ---- - -### Edge Cases - -- What happens when `pnpm` is not installed or not in PATH? The hook should fail with a clear error message. -- What happens when `node_modules` are not installed? The hook should fail with a clear error message suggesting `pnpm install`. -- What happens when the hook is run outside the project root? The hook should resolve the project root correctly. -- What happens on a fresh clone? The hook must be automatically available after `pnpm install` without additional manual steps. - -## Requirements *(mandatory)* - -### Functional Requirements - -- **FR-001**: The repository MUST include a Git pre-commit hook that runs `pnpm check` before every commit. -- **FR-002**: The hook MUST block the commit (exit non-zero) if `pnpm check` fails. -- **FR-003**: The hook MUST allow the commit (exit zero) if `pnpm check` succeeds. -- **FR-004**: The hook MUST display the output from `pnpm check` so the developer can see what failed. -- **FR-005**: The hook MUST be automatically available to all developers after cloning and running `pnpm install` (no manual hook installation steps). -- **FR-006**: The hook MUST be bypassable using the standard `git commit --no-verify` flag. -- **FR-007**: The hook MUST provide a clear error message if `pnpm` is not available. - -## Success Criteria *(mandatory)* - -### Measurable Outcomes - -- **SC-001**: 100% of commits made without `--no-verify` are validated by `pnpm check` before being accepted. -- **SC-002**: Developers see check results within the normal `pnpm check` execution time -- the hook adds no meaningful overhead beyond running the checks themselves. -- **SC-003**: New contributors can clone the repository, run `pnpm install`, and have the pre-commit gate active without any additional setup steps. -- **SC-004**: Developers can identify the specific failing check (format, lint, typecheck, or test) from the hook output alone. - -## Assumptions - -- The project already has a working `pnpm check` command that runs format, lint, typecheck, and test checks. -- All developers use Git for version control. -- The hook management approach uses Lefthook, a lightweight Git hooks manager distributed as an npm package, with a `prepare` script for auto-installation. -- MVP baseline does not include partial/staged-only checking (e.g., lint-staged). The full `pnpm check` runs on the entire project. diff --git a/specs/006-pre-commit-gate/tasks.md b/specs/006-pre-commit-gate/tasks.md deleted file mode 100644 index e99e2cd..0000000 --- a/specs/006-pre-commit-gate/tasks.md +++ /dev/null @@ -1,105 +0,0 @@ -# Tasks: Pre-Commit Gate - -**Input**: Design documents from `/specs/006-pre-commit-gate/` -**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, quickstart.md - -**Tests**: No test tasks included (not requested in feature specification). Verification is manual. - -**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. - -## Format: `[ID] [P?] [Story] Description` - -- **[P]**: Can run in parallel (different files, no dependencies) -- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) -- Include exact file paths in descriptions - -## Phase 1: Setup (Shared Infrastructure) - -**Purpose**: Install Lefthook and configure auto-install mechanism - -- [x] T001 Add `lefthook` as a devDependency and add a `prepare` script that runs `lefthook install` in `package.json` -- [x] T002 Run `pnpm install` to install lefthook and activate the prepare script - -**Checkpoint**: `lefthook` is installed and `pnpm install` triggers `lefthook install` automatically - ---- - -## Phase 2: User Story 1 & 2 - Block Failing / Allow Passing Commits (Priority: P1) MVP - -**Goal**: Create the Lefthook pre-commit hook configuration that runs `pnpm check`, blocking commits on failure and allowing commits on success. - -**Independent Test (US1)**: Introduce a deliberate lint error, run `git commit`, and verify the commit is blocked with visible error output. - -**Independent Test (US2)**: Make a valid change, run `git commit`, and verify the commit succeeds after `pnpm check` passes. - -### Implementation - -- [x] T003 [US1] [US2] Create `lefthook.yml` at repository root with a `pre-commit` hook that runs `pnpm check` - -**Checkpoint**: Commits are blocked when `pnpm check` fails (US1) and allowed when it passes (US2). Output from `pnpm check` is visible to the developer. - ---- - -## Phase 3: User Story 3 - Bypass Gate in Emergencies (Priority: P2) - -**Goal**: Ensure the standard `git commit --no-verify` flag bypasses the pre-commit hook. - -**Independent Test**: Stage a change that would fail checks, run `git commit --no-verify`, and verify the commit succeeds without running checks. - -### Implementation - -No implementation task needed -- Lefthook respects `--no-verify` by default (standard Git behavior). This phase exists for verification only. - -**Checkpoint**: `git commit --no-verify` bypasses the pre-commit gate. - ---- - -## Phase 4: Polish & Cross-Cutting Concerns - -**Purpose**: Edge case handling and validation - -- [x] T004 Verify the hook provides a clear error when `pnpm` is not in PATH (FR-007) and when `node_modules` are missing (edge case) -- [x] T005 Run quickstart.md validation: clone-install-commit workflow works end-to-end (SC-003) - ---- - -## Dependencies & Execution Order - -### Phase Dependencies - -- **Setup (Phase 1)**: No dependencies -- start immediately -- **US1 & US2 (Phase 2)**: Depends on Phase 1 (lefthook must be installed) -- **US3 (Phase 3)**: No implementation needed -- verify after Phase 2 -- **Polish (Phase 4)**: Depends on Phase 2 completion - -### Parallel Opportunities - -- T001 and T003 touch different files (`package.json` vs `lefthook.yml`) but T003 depends on lefthook being installed, so they must be sequential. -- T004 and T005 can run in parallel after Phase 2. - ---- - -## Implementation Strategy - -### MVP First (User Stories 1 & 2) - -1. Complete Phase 1: Install lefthook + prepare script -2. Complete Phase 2: Create `lefthook.yml` with pre-commit hook -3. **STOP and VALIDATE**: Test both blocking and allowing commits -4. Verify US3 bypass works (no implementation needed) - -### Execution Summary - -Total: **5 tasks** across 4 phases -- Phase 1 (Setup): 2 tasks -- Phase 2 (US1 + US2): 1 task -- Phase 3 (US3): 0 tasks (verification only) -- Phase 4 (Polish): 2 tasks - ---- - -## Notes - -- US1 and US2 are implemented by the same single task (T003) because they are two sides of the same coin: the hook either blocks or allows based on `pnpm check` exit code. -- US3 requires no implementation -- `--no-verify` is standard Git behavior that Lefthook respects. -- This is a minimal-footprint feature: 1 new file (`lefthook.yml`), 1 modified file (`package.json`). diff --git a/specs/007-add-knip/checklists/requirements.md b/specs/007-add-knip/checklists/requirements.md deleted file mode 100644 index 6880a39..0000000 --- a/specs/007-add-knip/checklists/requirements.md +++ /dev/null @@ -1,34 +0,0 @@ -# Specification Quality Checklist: Add Knip Unused Code Detection - -**Purpose**: Validate specification completeness and quality before proceeding to planning -**Created**: 2026-03-05 -**Feature**: [spec.md](../spec.md) - -## Content Quality - -- [x] No implementation details (languages, frameworks, APIs) -- [x] Focused on user value and business needs -- [x] Written for non-technical stakeholders -- [x] All mandatory sections completed - -## Requirement Completeness - -- [x] No [NEEDS CLARIFICATION] markers remain -- [x] Requirements are testable and unambiguous -- [x] Success criteria are measurable -- [x] Success criteria are technology-agnostic (no implementation details) -- [x] All acceptance scenarios are defined -- [x] Edge cases are identified -- [x] Scope is clearly bounded -- [x] Dependencies and assumptions identified - -## Feature Readiness - -- [x] All functional requirements have clear acceptance criteria -- [x] User scenarios cover primary flows -- [x] Feature meets measurable outcomes defined in Success Criteria -- [x] No implementation details leak into specification - -## Notes - -- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`. diff --git a/specs/007-add-knip/data-model.md b/specs/007-add-knip/data-model.md deleted file mode 100644 index af0523b..0000000 --- a/specs/007-add-knip/data-model.md +++ /dev/null @@ -1,24 +0,0 @@ -# Data Model: Add Knip Unused Code Detection - -**Date**: 2026-03-05 - -## Overview - -This feature is a developer tooling integration. It introduces no domain entities, state transitions, or persistent data. The only artifacts are configuration files. - -## Configuration Artifacts - -### Knip Configuration (`knip.json`) - -- **Purpose**: Defines workspace scope and any overrides for Knip's auto-detection. -- **Location**: Repository root. -- **Key fields**: `$schema`, `workspaces` (maps workspace glob patterns to per-workspace config). - -### Root Package Scripts (modification) - -- **Artifact**: `package.json` `scripts` field. -- **Change**: Add `knip` script; update `check` script to include Knip in the quality gate chain. - -## No Domain Impact - -This feature does not modify domain types, application use cases, or adapter code. It only adds a static analysis tool to the build/check pipeline. diff --git a/specs/007-add-knip/plan.md b/specs/007-add-knip/plan.md deleted file mode 100644 index 59f591c..0000000 --- a/specs/007-add-knip/plan.md +++ /dev/null @@ -1,128 +0,0 @@ -# Implementation Plan: Add Knip Unused Code Detection - -**Branch**: `007-add-knip` | **Date**: 2026-03-05 | **Spec**: [spec.md](./spec.md) -**Input**: Feature specification from `/specs/007-add-knip/spec.md` - -## Summary - -Add Knip v5 as a root devDependency to detect unused files, exports, dependencies, devDependencies, and types across the pnpm workspace. Configure it with a workspace-aware `knip.json`, expose a standalone `pnpm knip` command, and integrate it into the existing `pnpm check` quality gate so it runs on every commit via Lefthook. - -## Technical Context - -**Language/Version**: TypeScript 5.x (strict mode, verbatimModuleSyntax) -**Primary Dependencies**: Knip v5 (new), Biome 2.0, Vitest, Vite 6, React 19 -**Storage**: N/A -**Testing**: Vitest (existing); manual verification of Knip output -**Target Platform**: Node.js (developer tooling) -**Project Type**: pnpm monorepo (packages/domain, packages/application, apps/web) -**Performance Goals**: N/A (developer-time static analysis) -**Constraints**: Must pass on the current codebase with zero false positives -**Scale/Scope**: 3 workspace packages - -## Constitution Check - -*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* - -| Principle | Status | Notes | -|-----------|--------|-------| -| I. Deterministic Domain Core | PASS | No domain changes. | -| II. Layered Architecture | PASS | No layer changes; Knip is root-level tooling only. | -| III. Agent Boundary | PASS | No agent layer involved. | -| IV. Clarification-First | PASS | Feature is well-defined; no ambiguities. | -| V. Escalation Gates | PASS | Implementing per spec. | -| VI. MVP Baseline Language | PASS | No scope restrictions introduced. | -| VII. No Gameplay Rules | PASS | Tooling feature only. | -| Dev Workflow: Automated checks | PASS | Knip becomes part of the merge gate (`pnpm check`). | - -**Post-design re-check**: All gates still pass. No design decisions impact domain, layers, or architectural boundaries. - -## Project Structure - -### Documentation (this feature) - -```text -specs/007-add-knip/ -├── spec.md -├── plan.md # This file -├── research.md -├── data-model.md -├── quickstart.md -└── checklists/ - └── requirements.md -``` - -### Source Code (repository root) - -```text -# Files modified -package.json # Add knip devDep, add "knip" script, update "check" script - -# Files created -knip.json # Root-level Knip workspace configuration -``` - -**Structure Decision**: No new source directories. This feature only adds a config file and modifies the root `package.json` scripts. The existing monorepo structure (`packages/*`, `apps/*`) is referenced by Knip's workspace config but not changed. - -## Implementation Details - -### 1. Install Knip - -Add `knip` as a root devDependency: - -```bash -pnpm add -Dw knip -``` - -### 2. Create `knip.json` - -Root-level configuration leveraging auto-detection: - -```json -{ - "$schema": "https://unpkg.com/knip@5/schema.json", - "entry": ["scripts/*.mjs"], - "workspaces": { - "packages/*": {}, - "apps/*": {} - } -} -``` - -Knip auto-detects: -- pnpm workspace packages from `pnpm-workspace.yaml` -- Entry points from each `package.json` (`main`, `exports`, `types`) -- TypeScript config from `tsconfig.json` files (including project references) -- Vitest test patterns from `vitest.config.ts` -- Vite config and plugins from `vite.config.ts` -- Biome config from `biome.json` - -### 3. Update `package.json` Scripts - -Add standalone command and integrate into quality gate: - -```json -{ - "scripts": { - "knip": "knip", - "check": "knip && biome check . && tsc --build && vitest run" - } -} -``` - -Knip runs first because it's fast and catches structural issues before heavier checks. - -### 4. Validate - -1. Run `pnpm knip` — must pass clean on the current codebase (SC-002). -2. If false positives appear, tune `knip.json` with `ignore`, `ignoreDependencies`, or plugin-specific overrides. -3. Run `pnpm check` — full gate must pass (SC-001). -4. Introduce an intentional unused export → verify `pnpm knip` catches it (SC-001). -5. Remove the intentional unused export → verify clean again. - -### 5. Update Agent Context - -Run `.specify/scripts/bash/update-agent-context.sh claude` to register Knip as a project technology. - -## Complexity Tracking - -No constitution violations. No complexity justifications needed. diff --git a/specs/007-add-knip/quickstart.md b/specs/007-add-knip/quickstart.md deleted file mode 100644 index 77d3345..0000000 --- a/specs/007-add-knip/quickstart.md +++ /dev/null @@ -1,29 +0,0 @@ -# Quickstart: Add Knip Unused Code Detection - -**Date**: 2026-03-05 - -## What This Feature Does - -Adds Knip to the project to detect unused files, exports, dependencies, devDependencies, and types across the pnpm workspace. Enforces it as part of the `pnpm check` quality gate (which runs on every commit via Lefthook). - -## Files Changed - -| File | Change | -|------|--------| -| `package.json` | Add `knip` devDependency; add `knip` script; update `check` script | -| `knip.json` (new) | Workspace-aware Knip configuration | - -## How to Use - -```bash -# Run unused-code check standalone -pnpm knip - -# Run full quality gate (now includes Knip) -pnpm check -``` - -## Verification - -1. `pnpm check` passes on the current codebase (no false positives). -2. Add an unused export to any file → `pnpm knip` reports it → `pnpm check` fails. diff --git a/specs/007-add-knip/research.md b/specs/007-add-knip/research.md deleted file mode 100644 index 509d8ad..0000000 --- a/specs/007-add-knip/research.md +++ /dev/null @@ -1,41 +0,0 @@ -# Research: Add Knip Unused Code Detection - -**Date**: 2026-03-05 - -## Decision 1: Knip Configuration Approach - -**Decision**: Use workspace-aware `knip.json` at the repo root with minimal explicit configuration, relying on Knip's auto-detection for plugins and entry points. - -**Rationale**: Knip v5 auto-detects pnpm workspaces from `pnpm-workspace.yaml` and enables plugins (Vite, Vitest, TypeScript, Biome) based on `package.json` dependencies. The project follows standard conventions, so auto-detection covers most cases. Explicit workspace entries in `knip.json` provide a safety net and clear documentation of scope. - -**Alternatives considered**: -- Zero config (no `knip.json`): Works but less explicit; harder for contributors to understand what's scanned. -- Per-package configs: Unnecessary complexity; root-level workspace config covers the monorepo. - -## Decision 2: Quality Gate Integration - -**Decision**: Add `knip` as a separate script in root `package.json` and chain it into the existing `check` script. - -**Rationale**: The current `check` script is `biome check . && tsc --build && vitest run`. Adding `knip` to this chain (e.g., `knip && biome check . && ...`) makes it part of the pre-commit gate via Lefthook without any Lefthook config changes. Running Knip first is efficient since it's fast and catches structural issues before heavier checks. - -**Alternatives considered**: -- Separate Lefthook job: Adds config complexity; the existing single `pnpm check` job is cleaner. -- Only standalone command (not in gate): Doesn't enforce the quality bar on every commit. - -## Decision 3: Handling the `scripts/` Directory - -**Decision**: Configure Knip to recognize `scripts/check-layer-boundaries.mjs` as an entry point so it isn't flagged as unused. - -**Rationale**: This script is imported by Vitest tests but lives outside the standard workspace packages. Knip needs to know it's intentionally referenced. The root workspace can include it as an entry pattern. - -**Alternatives considered**: -- Ignoring the scripts directory entirely: Would miss actual unused scripts in the future. - -## Decision 4: Knip Version - -**Decision**: Install latest Knip v5 (`knip@5`). - -**Rationale**: v5 is the current stable major version with full pnpm workspace support and 138+ built-in plugins. - -**Alternatives considered**: -- Pinning exact version: Less flexible for patch updates; caret range (`^5`) is standard practice. diff --git a/specs/007-add-knip/spec.md b/specs/007-add-knip/spec.md deleted file mode 100644 index 725eabc..0000000 --- a/specs/007-add-knip/spec.md +++ /dev/null @@ -1,79 +0,0 @@ -# Feature Specification: Add Knip Unused Code Detection - -**Feature Branch**: `007-add-knip` -**Created**: 2026-03-05 -**Status**: Draft -**Input**: User description: "Add Knip to the project to detect unused files, exports, dependencies, devDependencies, and types across the pnpm workspace and enforce it as part of the quality gate." - -## User Scenarios & Testing *(mandatory)* - -### User Story 1 - Detect Unused Code on Commit (Priority: P1) - -As a developer, I want unused files, exports, dependencies, devDependencies, and types to be automatically detected when I commit, so that dead code never accumulates in the codebase. - -**Why this priority**: This is the core value proposition — catching unused code as part of the existing quality gate ensures every commit keeps the codebase clean without requiring manual effort. - -**Independent Test**: Can be fully tested by introducing an unused export into any workspace package and running the quality gate; the gate should fail with a clear report identifying the unused export. - -**Acceptance Scenarios**: - -1. **Given** the quality gate is run, **When** all files, exports, dependencies, and types are in use, **Then** the unused-code check passes successfully. -2. **Given** a file contains an unused export, **When** the quality gate is run, **Then** the check fails and reports the specific unused export and its file location. -3. **Given** a workspace package lists a dependency that is never imported, **When** the quality gate is run, **Then** the check fails and reports the unused dependency and the package it belongs to. -4. **Given** a file exists that is not imported or referenced anywhere, **When** the quality gate is run, **Then** the check fails and reports the unused file. -5. **Given** a type or interface is exported but never imported elsewhere, **When** the quality gate is run, **Then** the check fails and reports the unused type. - ---- - -### User Story 2 - Run Unused-Code Check Independently (Priority: P2) - -As a developer, I want to run the unused-code detection as a standalone command, so that I can inspect and fix issues before committing. - -**Why this priority**: Developers need a fast feedback loop to discover and address unused code during development, not just at commit time. - -**Independent Test**: Can be tested by running a dedicated command from the workspace root and verifying it produces output listing any unused items found across all workspace packages. - -**Acceptance Scenarios**: - -1. **Given** the developer is at the workspace root, **When** they run the standalone unused-code command, **Then** it analyzes all workspace packages and reports results. -2. **Given** unused items exist across multiple workspace packages, **When** the standalone command is run, **Then** all unused items are reported with their package and file location. - ---- - -### Edge Cases - -- What happens when a file is only used as an entry point (e.g., `main` or `exports` field in `package.json`)? It must not be falsely reported as unused. -- What happens when test files import modules only used in tests? Test-only dependencies and test utilities must not be flagged. -- What happens when configuration files (e.g., `vite.config.ts`, `vitest.config.ts`) reference plugins or packages? These must not be flagged as unused. -- What happens when workspace packages cross-reference each other via the `workspace:*` protocol? Internal workspace dependencies must be recognized. -- What happens when a type is re-exported from a barrel file? The re-export chain must be traced correctly. - -## Requirements *(mandatory)* - -### Functional Requirements - -- **FR-001**: The system MUST detect unused files across all workspace packages (`packages/domain`, `packages/application`, `apps/web`). -- **FR-002**: The system MUST detect unused exports (functions, constants, types, interfaces) across all workspace packages. -- **FR-003**: The system MUST detect unused `dependencies` and `devDependencies` listed in each workspace package's `package.json`. -- **FR-004**: The system MUST be integrated into the existing quality gate (`pnpm check`) so that any unused code causes the gate to fail. -- **FR-005**: The system MUST provide a standalone command runnable from the workspace root to check for unused code independently of the full quality gate. -- **FR-006**: The system MUST correctly recognize entry points defined in each package's `package.json` (`main`, `exports`, `types` fields) and not flag them as unused. -- **FR-007**: The system MUST correctly handle pnpm workspace cross-references (`workspace:*` protocol) and not flag internal workspace dependencies as unused. -- **FR-008**: The system MUST correctly recognize test file patterns and not flag test-only utilities or test dependencies as unused when they are consumed by tests. -- **FR-009**: The system MUST correctly recognize configuration files and their plugin/dependency references. - -## Success Criteria *(mandatory)* - -### Measurable Outcomes - -- **SC-001**: The quality gate fails when any unused file, export, dependency, or type is present, with zero false negatives for straightforward unused items. -- **SC-002**: The quality gate passes on the current codebase without false positives (no legitimate code is flagged as unused). -- **SC-003**: A developer can run the standalone unused-code check and receive results covering all workspace packages. -- **SC-004**: The unused-code report identifies the specific item (file, export name, dependency name) and its location (package and file path) for each finding. - -## Assumptions - -- Knip is the chosen tool for unused-code detection as specified by the user. -- The tool will be added as a root-level development dependency since it analyzes the entire workspace. -- Knip's built-in support for pnpm workspaces, TypeScript, Vitest, React, and Vite will handle most configuration automatically with minimal manual setup. -- The existing Lefthook pre-commit hook runs `pnpm check`, so adding the unused-code check to `pnpm check` automatically enforces it on every commit. diff --git a/specs/007-add-knip/tasks.md b/specs/007-add-knip/tasks.md deleted file mode 100644 index 0150c49..0000000 --- a/specs/007-add-knip/tasks.md +++ /dev/null @@ -1,122 +0,0 @@ -# Tasks: Add Knip Unused Code Detection - -**Input**: Design documents from `/specs/007-add-knip/` -**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, quickstart.md - -**Tests**: No automated test tasks — this is a tooling feature validated by running `pnpm knip` and `pnpm check`. - -**Organization**: Tasks follow the two user stories (US1: quality gate enforcement, US2: standalone command). - -## Format: `[ID] [P?] [Story] Description` - -- **[P]**: Can run in parallel (different files, no dependencies) -- **[Story]**: Which user story this task belongs to (e.g., US1, US2) -- Include exact file paths in descriptions - -## Phase 1: Setup - -**Purpose**: Install Knip and create configuration - -- [x] T001 Install Knip v5 as a root devDependency via `pnpm add -Dw knip` -- [x] T002 Create `knip.json` at the repository root with workspace-aware configuration covering `packages/*` and `apps/*`, including `$schema` for editor support - ---- - -## Phase 2: Foundational (Blocking Prerequisites) - -**Purpose**: Ensure Knip passes cleanly on the current codebase before integrating into the gate - -**CRITICAL**: Must resolve all false positives before wiring into the quality gate - -- [x] T003 Run `pnpm knip` against the current codebase and capture output -- [x] T004 If false positives are reported, tune `knip.json` with `ignore`, `ignoreDependencies`, `entry`, or plugin-specific overrides until the codebase passes cleanly (FR-006 through FR-009) - -**Checkpoint**: `pnpm knip` exits with code 0 on the current codebase (SC-002) - ---- - -## Phase 3: User Story 1 - Detect Unused Code on Commit (Priority: P1) - -**Goal**: Integrate Knip into the `pnpm check` quality gate so unused code is caught on every commit via Lefthook. - -**Independent Test**: Introduce an unused export in any workspace package, run `pnpm check`, and confirm it fails with a clear report. Remove the unused export and confirm `pnpm check` passes. - -### Implementation for User Story 1 - -- [x] T005 [US1] Update the `check` script in `package.json` to prepend `knip &&` before `biome check .` so unused code is checked as part of the quality gate -- [x] T006 [US1] Run `pnpm check` end-to-end and verify it passes on the current codebase (SC-001, SC-002) -- [x] T007 [US1] Manually verify detection: (a) add a temporary unused export to `packages/domain/src/index.ts`, run `pnpm check`, confirm it fails with a report identifying the unused export and its file location (SC-001, SC-004), then remove the temporary change; (b) verify that exports re-exported through barrel files (e.g., `index.ts`) are correctly traced and not falsely flagged - -**Checkpoint**: Quality gate enforces unused-code detection on every commit. US1 acceptance scenarios 1–5 are satisfied. - ---- - -## Phase 4: User Story 2 - Run Unused-Code Check Independently (Priority: P2) - -**Goal**: Provide a standalone `pnpm knip` command developers can run without the full quality gate. - -**Independent Test**: Run `pnpm knip` from the workspace root and verify it analyzes all three workspace packages and reports results. - -### Implementation for User Story 2 - -- [x] T008 [US2] Add a `"knip": "knip"` script to `package.json` so developers can run `pnpm knip` independently -- [x] T009 [US2] Verify `pnpm knip` runs from the workspace root and reports results covering all workspace packages (`packages/domain`, `packages/application`, `apps/web`) (SC-003, SC-004) - -**Checkpoint**: Developers can run `pnpm knip` standalone. US2 acceptance scenarios 1–2 are satisfied. - ---- - -## Phase 5: Polish & Cross-Cutting Concerns - -**Purpose**: Final validation and documentation - -- [x] T010 Run quickstart.md validation: confirm `pnpm knip` and `pnpm check` both work as documented -- [x] T011 Update CLAUDE.md commands section if `pnpm knip` should be listed as a project command - ---- - -## Dependencies & Execution Order - -### Phase Dependencies - -- **Setup (Phase 1)**: No dependencies — start immediately -- **Foundational (Phase 2)**: Depends on Phase 1 (T001, T002 must complete first) -- **User Story 1 (Phase 3)**: Depends on Phase 2 (clean Knip pass required before wiring into gate) -- **User Story 2 (Phase 4)**: Depends on Phase 3 (T008 modifies the same `package.json` as T005, so must follow it) -- **Polish (Phase 5)**: Depends on Phases 3 and 4 - -### User Story Dependencies - -- **User Story 1 (P1)**: Depends on Foundational phase — needs clean codebase pass before gate integration -- **User Story 2 (P2)**: Depends on US1 — T008 modifies the same `package.json` as T005, so must execute after it - -### Parallel Opportunities - -- T001 and T002 are sequential (T002 needs Knip installed for schema validation) -- T008 (US2) modifies `package.json` (same as T005), so execute sequentially after T005 - ---- - -## Implementation Strategy - -### MVP First (User Story 1 Only) - -1. Complete Phase 1: Install Knip, create config -2. Complete Phase 2: Ensure clean pass on current codebase -3. Complete Phase 3: Wire into quality gate -4. **STOP and VALIDATE**: `pnpm check` passes clean; intentional unused code is caught - -### Incremental Delivery - -1. Phase 1 + Phase 2 → Knip works locally -2. Add US1 (Phase 3) → Quality gate enforced on every commit (MVP!) -3. Add US2 (Phase 4) → Standalone `pnpm knip` command available -4. Phase 5 → Documentation updated - ---- - -## Notes - -- All tasks modify root-level files only (no domain/application/adapter changes) -- The Lefthook pre-commit hook already runs `pnpm check`, so no Lefthook config changes needed -- If Knip reports false positives in Phase 2, the most common fixes are `ignoreDependencies` for tooling packages and `entry` patterns for non-standard entry points diff --git a/specs/008-persist-encounter/checklists/requirements.md b/specs/008-persist-encounter/checklists/requirements.md deleted file mode 100644 index a347061..0000000 --- a/specs/008-persist-encounter/checklists/requirements.md +++ /dev/null @@ -1,34 +0,0 @@ -# Specification Quality Checklist: Persist Encounter - -**Purpose**: Validate specification completeness and quality before proceeding to planning -**Created**: 2026-03-05 -**Feature**: [spec.md](../spec.md) - -## Content Quality - -- [x] No implementation details (languages, frameworks, APIs) -- [x] Focused on user value and business needs -- [x] Written for non-technical stakeholders -- [x] All mandatory sections completed - -## Requirement Completeness - -- [x] No [NEEDS CLARIFICATION] markers remain -- [x] Requirements are testable and unambiguous -- [x] Success criteria are measurable -- [x] Success criteria are technology-agnostic (no implementation details) -- [x] All acceptance scenarios are defined -- [x] Edge cases are identified -- [x] Scope is clearly bounded -- [x] Dependencies and assumptions identified - -## Feature Readiness - -- [x] All functional requirements have clear acceptance criteria -- [x] User scenarios cover primary flows -- [x] Feature meets measurable outcomes defined in Success Criteria -- [x] No implementation details leak into specification - -## Notes - -- All items pass validation. Spec is ready for `/speckit.clarify` or `/speckit.plan`. diff --git a/specs/008-persist-encounter/data-model.md b/specs/008-persist-encounter/data-model.md deleted file mode 100644 index 20bc4c9..0000000 --- a/specs/008-persist-encounter/data-model.md +++ /dev/null @@ -1,50 +0,0 @@ -# Data Model: Persist Encounter - -## Existing Entities (unchanged) - -### Combatant -| Field | Type | Notes | -|-------|------|-------| -| id | CombatantId (branded string) | Unique identifier | -| name | string | Display name | -| initiative | number or undefined | Initiative value, optional | - -#### ID Format Convention (existing code) -- Demo combatants use plain numeric IDs: `"1"`, `"2"`, `"3"` -- User-added combatants use the pattern `c-{N}` (e.g., `"c-1"`, `"c-2"`) -- On reload, `nextId` counter is derived by scanning existing IDs matching `c-{N}` and starting from `max(N) + 1` -- IDs not matching `c-{N}` (e.g., demo IDs) are ignored during counter derivation - -### Encounter -| Field | Type | Notes | -|-------|------|-------| -| combatants | readonly Combatant[] | Ordered list of combatants | -| activeIndex | number | Index of the combatant whose turn it is | -| roundNumber | number | Current round (positive integer) | - -## Persisted State - -### Storage Key -`"initiative:encounter"` - -### Serialized Format -The `Encounter` object is serialized as-is via `JSON.stringify`. The branded `CombatantId` serializes as a plain string. On deserialization, IDs are rehydrated with `combatantId()`. - -### Validation on Load -1. Parse JSON string into unknown value -2. Structural check: verify it is an object with `combatants` (array), `activeIndex` (number), `roundNumber` (number) -3. Verify each combatant has `id` (string) and `name` (string) -4. Pass through `createEncounter` to enforce domain invariants -5. On any failure: discard and return `null` (caller falls back to demo encounter) - -### State Transitions - -``` -App Load - ├─ localStorage has valid data → restore Encounter - └─ localStorage empty/invalid → create demo Encounter - -State Change (any use case) - └─ new Encounter saved to React state - └─ useEffect triggers → serialize to localStorage -``` diff --git a/specs/008-persist-encounter/plan.md b/specs/008-persist-encounter/plan.md deleted file mode 100644 index 21cc402..0000000 --- a/specs/008-persist-encounter/plan.md +++ /dev/null @@ -1,74 +0,0 @@ -# Implementation Plan: Persist Encounter - -**Branch**: `008-persist-encounter` | **Date**: 2026-03-05 | **Spec**: [spec.md](./spec.md) -**Input**: Feature specification from `/specs/008-persist-encounter/spec.md` - -## Summary - -Persist the current encounter state to browser localStorage so it survives page reloads. The web adapter layer will serialize encounter state on every change and deserialize on load, falling back to the demo encounter when no valid data exists. The domain and application layers remain unchanged -- persistence is purely an adapter concern. - -## Technical Context - -**Language/Version**: TypeScript 5.x (strict mode, verbatimModuleSyntax) -**Primary Dependencies**: React 19, Vite 6, Biome 2.0, existing domain/application packages -**Storage**: Browser localStorage (adapter layer only) -**Testing**: Vitest (unit tests for serialization/deserialization logic) -**Target Platform**: Modern browsers (Chrome, Firefox, Safari, Edge) -**Project Type**: Web application (monorepo with domain/application/web layers) -**Performance Goals**: Encounter restore under 1 second (negligible for small JSON payloads) -**Constraints**: No domain or application layer changes; persistence is adapter-only -**Scale/Scope**: Single encounter, single user, single tab (MVP baseline) - -## Constitution Check - -*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* - -| Principle | Status | Notes | -|-----------|--------|-------| -| I. Deterministic Domain Core | PASS | No domain changes. localStorage access is confined to the adapter layer. | -| II. Layered Architecture | PASS | Persistence logic lives in the web adapter (apps/web). Domain and application layers are untouched. The existing `EncounterStore` port is already implemented by the `useEncounter` hook; persistence wraps around this. | -| III. Agent Boundary | N/A | No agent features involved. | -| IV. Clarification-First | PASS | Feature is well-scoped; no non-trivial assumptions. Storage key name and serialization format are implementation details within adapter scope. | -| V. Escalation Gates | PASS | Implementation stays within spec scope. | -| VI. MVP Baseline Language | PASS | Cross-tab sync and multi-encounter support noted as "MVP baseline does not include." | -| VII. No Gameplay Rules | PASS | No gameplay mechanics involved. | - -**Gate result**: PASS -- no violations. - -## Project Structure - -### Documentation (this feature) - -```text -specs/008-persist-encounter/ -├── plan.md # This file -├── research.md # Phase 0 output -├── data-model.md # Phase 1 output -├── quickstart.md # Phase 1 output -└── tasks.md # Phase 2 output (via /speckit.tasks) -``` - -### Source Code (repository root) - -```text -packages/domain/src/ -├── types.ts # Encounter, Combatant, CombatantId (unchanged) -└── index.ts # Exports (unchanged) - -packages/application/src/ -├── ports.ts # EncounterStore interface (unchanged) -└── index.ts # Exports (unchanged) - -apps/web/src/ -├── hooks/ -│ └── use-encounter.ts # Modified: initialize from localStorage, persist on change -├── persistence/ -│ └── encounter-storage.ts # New: localStorage read/write/validate logic -└── main.tsx # Unchanged -``` - -**Structure Decision**: All new code goes into `apps/web/src/persistence/` as a new adapter module. The `useEncounter` hook is modified to use this module for initialization and persistence. No new packages or layers are introduced. - -## Complexity Tracking - -No constitution violations to justify. diff --git a/specs/008-persist-encounter/quickstart.md b/specs/008-persist-encounter/quickstart.md deleted file mode 100644 index 8dc885b..0000000 --- a/specs/008-persist-encounter/quickstart.md +++ /dev/null @@ -1,39 +0,0 @@ -# Quickstart: Persist Encounter - -## What This Feature Does - -Saves the current encounter to browser localStorage so it survives page reloads. No user action required -- saving and restoring happens automatically. - -## Key Files - -| File | Purpose | -|------|---------| -| `apps/web/src/persistence/encounter-storage.ts` | New: read/write/validate encounter in localStorage | -| `apps/web/src/hooks/use-encounter.ts` | Modified: load from storage on init, persist on change | - -## How It Works - -1. **On app load**: The `useEncounter` hook calls `loadEncounter()` from the storage module. If valid data exists, it initializes state from it. Otherwise, the demo encounter is used. -2. **On every state change**: A `useEffect` watches the encounter state and calls `saveEncounter()` to write it to localStorage. -3. **On error**: All storage operations are wrapped in try/catch. Failures are silent -- the app continues working in memory-only mode. - -## Testing - -```bash -pnpm vitest run apps/web/src/persistence/__tests__/encounter-storage.test.ts -``` - -Tests cover: -- Round-trip serialization (save then load returns same encounter) -- Invalid/corrupt data returns null -- Missing fields return null -- Non-JSON data returns null - -## Manual Verification - -1. Run `pnpm --filter web dev` -2. Add/remove combatants, set initiative, advance turns -3. Refresh the page -- encounter state should be preserved -4. Open DevTools > Application > localStorage to inspect the stored data -5. Delete the storage key and refresh -- demo encounter should appear -6. Set the storage value to `"garbage"` and refresh -- demo encounter should appear diff --git a/specs/008-persist-encounter/research.md b/specs/008-persist-encounter/research.md deleted file mode 100644 index 0fe9bd6..0000000 --- a/specs/008-persist-encounter/research.md +++ /dev/null @@ -1,62 +0,0 @@ -# Research: Persist Encounter - -## R1: Serialization Format for Encounter State - -**Decision**: JSON via `JSON.stringify` / `JSON.parse` - -**Rationale**: The `Encounter` type is a plain data structure (no classes, no functions, no circular references). JSON serialization is native to browsers, zero-dependency, and produces human-readable output for debugging. The branded `CombatantId` type serializes as a plain string and can be rehydrated with the `combatantId()` constructor. - -**Alternatives considered**: -- Structured clone / IndexedDB: Overkill for a single small object. Adds async complexity for no benefit. -- Custom binary format: Unnecessary complexity for small payloads. - -## R2: Validation Strategy on Load - -**Decision**: Validate deserialized data through the existing `createEncounter` domain function before accepting it. - -**Rationale**: `createEncounter` already enforces all encounter invariants (at least one combatant, valid activeIndex, valid roundNumber). Passing deserialized data through it ensures that corrupt or tampered data is rejected by the same rules that govern encounter creation. Additional structural checks (is it an object? does it have the right shape?) are needed before calling `createEncounter` since `JSON.parse` can return any type. - -**Alternatives considered**: -- Schema validation library (Zod, AJV): Adds a dependency for a single validation point. The domain function plus a lightweight shape check is sufficient. -- No validation (trust localStorage): Fragile; any manual edit or version mismatch would crash the app. - -## R3: Persistence Trigger - -**Decision**: Persist encounter state on every state change via a `useEffect` that watches the encounter value. - -**Rationale**: The encounter state changes infrequently (user actions only), so writing on every change has negligible performance impact. This is simpler than debouncing or batching, and guarantees the latest state is always saved. - -**Alternatives considered**: -- Debounced writes: Unnecessary complexity; encounter changes are user-driven and infrequent. -- Manual save button: Poor UX; users expect auto-save in modern apps. -- `beforeunload` event only: Unreliable; may not fire on mobile or crash scenarios. - -## R4: localStorage Key and Namespace - -**Decision**: Use a single key `"initiative:encounter"` with a namespaced prefix. - -**Rationale**: Namespacing avoids collisions with other apps on the same origin. A single key is sufficient for the MVP (one encounter at a time). - -**Alternatives considered**: -- Multiple keys (one per field): Unnecessarily complex; atomic read/write of the full encounter is simpler and avoids partial state issues. -- Versioned key (e.g., `initiative:encounter:v1`): The validation layer already handles schema mismatches by falling back to the demo encounter. An explicit version field inside the stored data could be added later if needed but is unnecessary for MVP. - -## R5: ID Counter Persistence - -**Decision**: Derive the next ID counter from existing combatant IDs on load rather than persisting it separately. - -**Rationale**: The `useEncounter` hook currently uses a `useRef(0)` counter with `c-{N}` format IDs. After a reload, we can scan existing combatant IDs, extract the highest numeric suffix, and start the counter from there. This avoids persisting a separate counter value and keeps the storage format simple. - -**Alternatives considered**: -- Persist counter as a separate field: Works but adds coupling between the storage format and the hook's internal ID generation strategy. -- Use UUID for new IDs: Would work but changes the ID format; unnecessary for MVP single-user scope. - -## R6: Error Handling for Storage Operations - -**Decision**: Wrap all localStorage operations in try/catch. On write failure (quota exceeded, storage unavailable), silently continue. On read failure, fall back to demo encounter. - -**Rationale**: The app must never crash due to storage issues (FR-004). Silent failure on write means the user's current session is unaffected. Fallback on read means the app always starts in a usable state. - -**Alternatives considered**: -- Show a warning toast on write failure: Could be added later but is not in the spec scope. Silent failure is the simplest correct behavior for MVP. -- Retry logic: Unnecessary; if localStorage is unavailable, retrying won't help. diff --git a/specs/008-persist-encounter/spec.md b/specs/008-persist-encounter/spec.md deleted file mode 100644 index 21bbf25..0000000 --- a/specs/008-persist-encounter/spec.md +++ /dev/null @@ -1,90 +0,0 @@ -# Feature Specification: Persist Encounter - -**Feature Branch**: `008-persist-encounter` -**Created**: 2026-03-05 -**Status**: Draft -**Input**: User description: "Persist the encounter in browser localStorage so the current encounter survives page reloads." - -## User Scenarios & Testing *(mandatory)* - -### User Story 1 - Encounter Survives Page Reload (Priority: P1) - -A user is managing a combat encounter (combatants added, initiative set, turns advanced). They accidentally refresh the page or their browser restarts. When the page reloads, the encounter is restored exactly as it was -- same combatants, same initiative values, same active turn, same round number. - -**Why this priority**: This is the core value of the feature. Without persistence, all encounter progress is lost on any page navigation or reload, which is the primary pain point. - -**Independent Test**: Can be fully tested by setting up an encounter, refreshing the page, and verifying all encounter state is preserved. - -**Acceptance Scenarios**: - -1. **Given** an encounter with combatants, initiative values, active turn, and round number, **When** the user reloads the page, **Then** the encounter is restored with all state intact (combatants, initiative, active index, round number). -2. **Given** an encounter that has been modified (combatant added, removed, renamed, or initiative changed), **When** the user reloads the page, **Then** the latest state is reflected. -3. **Given** the user advances the turn multiple times, **When** the user reloads the page, **Then** the active turn and round number are preserved. - ---- - -### User Story 2 - Fresh Start with No Saved Data (Priority: P2) - -A first-time user opens the application with no previously saved encounter. The application shows the default demo encounter so the user can immediately start exploring. - -**Why this priority**: Ensures backward compatibility and a smooth first-use experience. - -**Independent Test**: Can be tested by clearing browser storage and loading the application, verifying the demo encounter appears. - -**Acceptance Scenarios**: - -1. **Given** no saved encounter exists in the browser, **When** the user opens the application, **Then** the default demo encounter is displayed. -2. **Given** saved encounter data has been manually cleared from the browser, **When** the user opens the application, **Then** the default demo encounter is displayed. - ---- - -### User Story 3 - Graceful Handling of Corrupt Data (Priority: P3) - -Saved data may become invalid (e.g., manually edited in dev tools, schema changes between versions). The application handles this gracefully rather than crashing. - -**Why this priority**: Protects the user experience from edge cases that would otherwise render the app unusable without manual intervention. - -**Independent Test**: Can be tested by writing malformed data to the storage key and loading the application. - -**Acceptance Scenarios**: - -1. **Given** the saved encounter data is malformed or unparseable, **When** the user opens the application, **Then** the default demo encounter is displayed and the corrupt data is discarded. -2. **Given** the saved data is missing required fields, **When** the user opens the application, **Then** the default demo encounter is displayed. - ---- - -### Edge Cases - -- What happens when browser storage quota is exceeded? The application continues to function normally; persistence silently fails without disrupting the user's current session. -- What happens when browser storage is unavailable (e.g., private browsing in some browsers)? The application falls back to in-memory-only behavior, functioning identically to the current experience. -- What happens when the user has multiple browser tabs open? MVP baseline does not include cross-tab synchronization. Each tab operates independently; the last tab to save wins. - -## Requirements *(mandatory)* - -### Functional Requirements - -- **FR-001**: System MUST save the full encounter state (combatants, activeIndex, roundNumber) to browser storage after every state change. -- **FR-002**: System MUST restore the saved encounter state when the application loads, if valid saved data exists. -- **FR-003**: System MUST fall back to the default demo encounter when no saved data exists or saved data is invalid. -- **FR-004**: System MUST NOT crash or show an error to the user when storage is unavailable or data is corrupt. -- **FR-005**: System MUST preserve combatant identity (IDs, names, initiative values) across reloads. -- **FR-006**: System MUST preserve the active turn position and round number across reloads. - -### Key Entities - -- **Persisted Encounter**: A serialized representation of the Encounter (combatants with IDs, names, and initiative values; activeIndex; roundNumber) stored in the browser. - -## Success Criteria *(mandatory)* - -### Measurable Outcomes - -- **SC-001**: Users can reload the page and see their encounter fully restored within 1 second, with zero data loss. -- **SC-002**: First-time users see the demo encounter immediately on first visit with no extra steps. -- **SC-003**: 100% of corrupt or missing data scenarios result in a usable application (demo encounter displayed), never a crash or blank screen. - -## Assumptions - -- The encounter state is small enough that serialization/deserialization has negligible performance impact. -- A single browser storage key is sufficient for the MVP (one encounter at a time). -- Cross-tab synchronization is not required for the MVP baseline. -- The ID counter for new combatants must also be persisted or derived from existing state so that new combatants added after a reload do not collide with existing IDs. diff --git a/specs/008-persist-encounter/tasks.md b/specs/008-persist-encounter/tasks.md deleted file mode 100644 index 66823e2..0000000 --- a/specs/008-persist-encounter/tasks.md +++ /dev/null @@ -1,145 +0,0 @@ -# Tasks: Persist Encounter - -**Input**: Design documents from `/specs/008-persist-encounter/` -**Prerequisites**: plan.md, spec.md, research.md, data-model.md - -**Tests**: Included -- persistence logic warrants unit tests to cover serialization, validation, and error handling. - -**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. - -## Format: `[ID] [P?] [Story] Description` - -- **[P]**: Can run in parallel (different files, no dependencies) -- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) -- Include exact file paths in descriptions - -## Phase 1: Setup - -**Purpose**: Create the persistence module structure - -- [x] T001 Create persistence module directory at `apps/web/src/persistence/` - ---- - -## Phase 2: Foundational (Blocking Prerequisites) - -**Purpose**: Core storage adapter that all user stories depend on - -- [x] T002 Implement `saveEncounter(encounter: Encounter): void` function in `apps/web/src/persistence/encounter-storage.ts` that serializes encounter state to localStorage under key `"initiative:encounter"` using `JSON.stringify`, wrapped in try/catch that silently swallows errors (quota exceeded, storage unavailable) -- [x] T003 Implement `loadEncounter(): Encounter | null` function in `apps/web/src/persistence/encounter-storage.ts` that reads from localStorage key `"initiative:encounter"`, parses JSON, performs structural shape checks (object with `combatants` array, `activeIndex` number, `roundNumber` number; each combatant has `id` string and `name` string), rehydrates `CombatantId` values via `combatantId()`, validates through `createEncounter`, and returns `null` on any failure (parse error, shape mismatch, domain validation failure) -- [x] T004 Write unit tests for `saveEncounter` and `loadEncounter` in `apps/web/src/persistence/__tests__/encounter-storage.test.ts` covering: round-trip save/load preserves encounter state, `loadEncounter` returns `null` when localStorage is empty, returns `null` for non-JSON strings, returns `null` for JSON missing required fields, returns `null` for invalid encounter data (e.g. empty combatants array, out-of-bounds activeIndex) - -**Checkpoint**: Storage adapter is complete and tested -- user story implementation can now begin - ---- - -## Phase 3: User Story 1 - Encounter Survives Page Reload (Priority: P1) MVP - -**Goal**: Encounter state persists across page reloads with zero data loss - -**Independent Test**: Set up an encounter with combatants, initiative values, and advanced turns. Call `saveEncounter`, then `loadEncounter` and verify all state matches. In the hook, verify `useEffect` triggers `saveEncounter` on state changes. - -### Tests for User Story 1 - -- [x] T005 [US1] Write tests in `apps/web/src/persistence/__tests__/encounter-storage.test.ts` for: round-trip preserves combatant IDs, names, and initiative values; round-trip preserves activeIndex and roundNumber; saving after modifications (add/remove combatant, change initiative) persists the latest state - -### Implementation for User Story 1 - -- [x] T006 [US1] Modify `useEncounter` hook in `apps/web/src/hooks/use-encounter.ts` to initialize state from `loadEncounter()` -- if it returns a valid encounter, use it instead of `createDemoEncounter()`; derive `nextId` counter from highest numeric suffix in existing combatant IDs (parse `c-{N}` pattern) -- [x] T007 [US1] Add a `useEffect` in `apps/web/src/hooks/use-encounter.ts` that calls `saveEncounter(encounter)` whenever the encounter state changes - -**Checkpoint**: User Story 1 is fully functional -- encounter survives page reload - ---- - -## Phase 4: User Story 2 - Fresh Start with No Saved Data (Priority: P2) - -**Goal**: First-time users see the default demo encounter - -**Independent Test**: With no localStorage data, load the app and verify the demo encounter (Aria, Brak, Cael) is displayed. - -### Implementation for User Story 2 - -- [x] T008 [US2] Verify and document in tests at `apps/web/src/persistence/__tests__/encounter-storage.test.ts` that `loadEncounter()` returns `null` when localStorage has no `"initiative:encounter"` key, confirming the `useEncounter` hook falls back to `createDemoEncounter()` - -**Checkpoint**: User Story 2 confirmed -- no saved data results in demo encounter - ---- - -## Phase 5: User Story 3 - Graceful Handling of Corrupt Data (Priority: P3) - -**Goal**: Corrupt or invalid saved data never crashes the app; falls back to demo encounter - -**Independent Test**: Write various malformed values to the `"initiative:encounter"` localStorage key and verify `loadEncounter()` returns `null` for each. - -### Tests for User Story 3 - -- [x] T009 [US3] Add tests in `apps/web/src/persistence/__tests__/encounter-storage.test.ts` for corrupt data scenarios: non-object JSON (string, number, array, null), object with wrong types for fields (combatants as string, activeIndex as string), combatant entries missing `id` or `name`, valid JSON structure but domain-invalid data (zero combatants, negative roundNumber) - -### Implementation for User Story 3 - -- [x] T010 [US3] Verify `loadEncounter()` structural checks in `apps/web/src/persistence/encounter-storage.ts` cover all corrupt data scenarios from T009 -- adjust shape validation if any test cases reveal gaps - -**Checkpoint**: All corrupt data scenarios handled gracefully - ---- - -## Phase 6: Polish & Cross-Cutting Concerns - -- [x] T011 Run `pnpm check` to verify formatting, linting, type checking, and all tests pass -- [x] T012 Run quickstart.md manual verification steps to validate end-to-end behavior - ---- - -## Dependencies & Execution Order - -### Phase Dependencies - -- **Setup (Phase 1)**: No dependencies -- **Foundational (Phase 2)**: Depends on Phase 1 -- BLOCKS all user stories -- **User Story 1 (Phase 3)**: Depends on Phase 2 -- **User Story 2 (Phase 4)**: Depends on Phase 2 (independent of US1) -- **User Story 3 (Phase 5)**: Depends on Phase 2 (independent of US1, US2) -- **Polish (Phase 6)**: Depends on all user stories complete - -### User Story Dependencies - -- **User Story 1 (P1)**: Depends on Foundational only. Core save/load wiring in the hook. -- **User Story 2 (P2)**: Depends on Foundational only. Verifies the null-fallback path. -- **User Story 3 (P3)**: Depends on Foundational only. Hardens validation against corrupt data. - -### Parallel Opportunities - -- T002 and T003 are sequential (same file, T003 depends on T002's storage key constant) -- T005 and T006 can run in parallel (test file vs hook file) -- T008 and T009 target the same test file as T004/T005 (`encounter-storage.test.ts`); sequence them after T005 to avoid merge conflicts. T006/T007 (hook file) can still run in parallel with test tasks. -- US2 and US3 can proceed in parallel after Foundational phase - ---- - -## Implementation Strategy - -### MVP First (User Story 1 Only) - -1. Complete Phase 1: Setup (T001) -2. Complete Phase 2: Foundational (T002-T004) -3. Complete Phase 3: User Story 1 (T005-T007) -4. **STOP and VALIDATE**: Run `pnpm check`, manually test reload behavior -5. This alone delivers the core value of the feature - -### Incremental Delivery - -1. Setup + Foundational -> Storage adapter ready -2. Add User Story 1 -> Reload persistence works (MVP!) -3. Add User Story 2 -> First-time experience confirmed -4. Add User Story 3 -> Corrupt data resilience hardened -5. Polish -> Final validation - ---- - -## Notes - -- All new code is in the adapter layer (`apps/web/`); domain and application packages are unchanged -- Tests use a localStorage mock (Vitest's jsdom environment or manual mock) -- The `nextId` counter derivation (T006) is critical to avoid ID collisions after reload -- Commit after each phase or logical group of tasks diff --git a/specs/009-combatant-hp/checklists/requirements.md b/specs/009-combatant-hp/checklists/requirements.md deleted file mode 100644 index a36825d..0000000 --- a/specs/009-combatant-hp/checklists/requirements.md +++ /dev/null @@ -1,34 +0,0 @@ -# Specification Quality Checklist: Combatant HP Tracking - -**Purpose**: Validate specification completeness and quality before proceeding to planning -**Created**: 2026-03-05 -**Feature**: [spec.md](../spec.md) - -## Content Quality - -- [x] No implementation details (languages, frameworks, APIs) -- [x] Focused on user value and business needs -- [x] Written for non-technical stakeholders -- [x] All mandatory sections completed - -## Requirement Completeness - -- [x] No [NEEDS CLARIFICATION] markers remain -- [x] Requirements are testable and unambiguous -- [x] Success criteria are measurable -- [x] Success criteria are technology-agnostic (no implementation details) -- [x] All acceptance scenarios are defined -- [x] Edge cases are identified -- [x] Scope is clearly bounded -- [x] Dependencies and assumptions identified - -## Feature Readiness - -- [x] All functional requirements have clear acceptance criteria -- [x] User scenarios cover primary flows -- [x] Feature meets measurable outcomes defined in Success Criteria -- [x] No implementation details leak into specification - -## Notes - -- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`. diff --git a/specs/009-combatant-hp/data-model.md b/specs/009-combatant-hp/data-model.md deleted file mode 100644 index 0cb70a0..0000000 --- a/specs/009-combatant-hp/data-model.md +++ /dev/null @@ -1,83 +0,0 @@ -# Data Model: Combatant HP Tracking - -**Feature**: 009-combatant-hp | **Date**: 2026-03-05 - -## Entities - -### Combatant (extended) - -| Field | Type | Required | Constraints | -|-------|------|----------|-------------| -| id | CombatantId (branded string) | Yes | Unique within encounter | -| name | string | Yes | Non-empty | -| initiative | number or undefined | No | Integer when set | -| **maxHp** | **number or undefined** | **No** | **Positive integer (>= 1) when set** | -| **currentHp** | **number or undefined** | **No** | **Integer in [0, maxHp] when set; requires maxHp** | - -**Invariants**: -- If `maxHp` is undefined, `currentHp` must also be undefined (no current HP without a max). -- If `maxHp` is defined, `currentHp` must be defined and satisfy `0 <= currentHp <= maxHp`. -- When `maxHp` is first set, `currentHp` defaults to `maxHp` (full health). -- When `maxHp` changes and `currentHp === maxHp` (full health), `currentHp` stays synced to the new `maxHp`. -- When `maxHp` changes and `currentHp < maxHp` (not full health), `currentHp` is unchanged unless it exceeds the new `maxHp`, in which case it is clamped. -- When `maxHp` is cleared (set to undefined), `currentHp` is also cleared. - -### Encounter (unchanged structure) - -No structural changes. The encounter continues to hold `readonly combatants: readonly Combatant[]`, `activeIndex`, and `roundNumber`. HP state lives on individual combatants. - -## Domain Events (new) - -### MaxHpSet - -Emitted when a combatant's max HP is set, changed, or cleared. - -| Field | Type | Description | -|-------|------|-------------| -| type | "MaxHpSet" | Event discriminant | -| combatantId | CombatantId | Target combatant | -| previousMaxHp | number or undefined | Max HP before change | -| newMaxHp | number or undefined | Max HP after change | -| previousCurrentHp | number or undefined | Current HP before change | -| newCurrentHp | number or undefined | Current HP after change (may differ due to clamping) | - -### CurrentHpAdjusted - -Emitted when a combatant's current HP is adjusted via +/- or direct entry. - -| Field | Type | Description | -|-------|------|-------------| -| type | "CurrentHpAdjusted" | Event discriminant | -| combatantId | CombatantId | Target combatant | -| previousHp | number | Current HP before adjustment | -| newHp | number | Current HP after adjustment (clamped) | -| delta | number | Requested change amount (positive = heal, negative = damage) | - -## State Transitions - -### setHp(encounter, combatantId, maxHp) - -``` -Input: Encounter, CombatantId, number | undefined -Output: { encounter: Encounter, events: [MaxHpSet] } | DomainError - -- combatant not found → DomainError("combatant-not-found") -- maxHp <= 0 or non-integer → DomainError("invalid-max-hp") -- maxHp = undefined → clear both maxHp and currentHp -- maxHp = N (new) → set maxHp=N, currentHp=N -- maxHp = N (changed, at full health: currentHp=prevMaxHp) → set maxHp=N, currentHp=N -- maxHp = N (changed, not full health) → set maxHp=N, currentHp=min(currentHp, N) -``` - -### adjustHp(encounter, combatantId, delta) - -``` -Input: Encounter, CombatantId, number -Output: { encounter: Encounter, events: [CurrentHpAdjusted] } | DomainError - -- combatant not found → DomainError("combatant-not-found") -- combatant has no maxHp set → DomainError("no-hp-tracking") -- delta = 0 → DomainError("zero-delta") -- delta non-integer → DomainError("invalid-delta") -- result → currentHp = clamp(currentHp + delta, 0, maxHp) -``` diff --git a/specs/009-combatant-hp/plan.md b/specs/009-combatant-hp/plan.md deleted file mode 100644 index cdc7638..0000000 --- a/specs/009-combatant-hp/plan.md +++ /dev/null @@ -1,79 +0,0 @@ -# Implementation Plan: Combatant HP Tracking - -**Branch**: `009-combatant-hp` | **Date**: 2026-03-05 | **Spec**: [spec.md](./spec.md) -**Input**: Feature specification from `/specs/009-combatant-hp/spec.md` - -## Summary - -Add optional max HP and current HP tracking to combatants. The domain layer gains pure functions for setting max HP and adjusting current HP (clamped to 0..max). The application layer orchestrates via the existing `EncounterStore` port. The web adapter adds +/- controls and direct numeric entry per combatant row. Persistence extends the existing localStorage serialization to include HP fields. - -## Technical Context - -**Language/Version**: TypeScript 5.x (strict mode, verbatimModuleSyntax) -**Primary Dependencies**: React 19, Vite 6, Biome 2.0, Vitest -**Storage**: Browser localStorage (adapter layer only) -**Testing**: Vitest (pure function tests in domain, use case tests in application) -**Target Platform**: Modern web browsers (single-user, local-first) -**Project Type**: Web application (monorepo with domain/application/web layers) -**Performance Goals**: Standard web app responsiveness; HP adjustments must feel instant -**Constraints**: Offline-capable, single-user MVP, no server -**Scale/Scope**: Single encounter at a time, typically 5-20 combatants - -## Constitution Check - -*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* - -| Principle | Status | Notes | -|-----------|--------|-------| -| I. Deterministic Domain Core | PASS | HP set/adjust are pure functions: (Encounter, CombatantId, value) -> (Encounter, Events) or DomainError. No I/O in domain. | -| II. Layered Architecture | PASS | Domain: pure HP functions. Application: use cases via EncounterStore port. Web: React adapter with +/- controls. No layer violations. | -| III. Agent Boundary | N/A | No agent features in this feature. | -| IV. Clarification-First | PASS | Spec has no NEEDS CLARIFICATION markers; all decisions documented in Assumptions. | -| V. Escalation Gates | PASS | Implementation stays within spec scope. | -| VI. MVP Baseline Language | PASS | Spec uses "not in the MVP baseline" for temp HP, death states, custom damage amounts. | -| VII. No Gameplay Rules in Constitution | PASS | HP clamping is spec-level behavior, not constitution-level. | - -## Project Structure - -### Documentation (this feature) - -```text -specs/009-combatant-hp/ -├── plan.md # This file -├── spec.md # Feature specification -├── research.md # Phase 0 output -├── data-model.md # Phase 1 output -├── quickstart.md # Phase 1 output -└── tasks.md # Phase 2 output (created by /speckit.tasks) -``` - -### Source Code (repository root) - -```text -packages/domain/src/ -├── types.ts # Extend Combatant with optional maxHp/currentHp -├── set-hp.ts # New: pure function for setting max HP -├── adjust-hp.ts # New: pure function for adjusting current HP (+/- delta) -├── events.ts # New events: MaxHpSet, CurrentHpAdjusted -├── index.ts # Re-export new functions -└── __tests__/ - ├── set-hp.test.ts # Tests for set-hp - └── adjust-hp.test.ts # Tests for adjust-hp - -packages/application/src/ -├── set-hp-use-case.ts # New: orchestrates set-hp via store -├── adjust-hp-use-case.ts # New: orchestrates adjust-hp via store -└── index.ts # Re-export new use cases - -apps/web/src/ -├── hooks/use-encounter.ts # Add setHp and adjustHp callbacks -├── persistence/ -│ └── encounter-storage.ts # Extend validation to include HP fields -└── App.tsx # Add HP controls to combatant rows -``` - -**Structure Decision**: Follows existing monorepo layered architecture. New domain functions follow the established one-file-per-operation pattern (matching `edit-combatant.ts`, `set-initiative.ts`). Two separate domain functions (`set-hp` for max HP, `adjust-hp` for current HP delta) keep concerns separated and make the design extensible for richer damage/heal operations later. - -## Complexity Tracking - -No constitution violations to justify. diff --git a/specs/009-combatant-hp/quickstart.md b/specs/009-combatant-hp/quickstart.md deleted file mode 100644 index f571023..0000000 --- a/specs/009-combatant-hp/quickstart.md +++ /dev/null @@ -1,48 +0,0 @@ -# Quickstart: Combatant HP Tracking - -**Feature**: 009-combatant-hp | **Date**: 2026-03-05 - -## Overview - -This feature adds optional health point (HP) tracking to combatants. Each combatant can optionally have a max HP and current HP. Both HP fields are always visible per combatant row (current HP disabled until max HP is set). The current HP is adjusted via +/- controls or direct entry, always clamped to [0, maxHp]. When max HP changes and the combatant is at full health, current HP stays synced. - -## Implementation Order - -1. **Domain types** — Extend `Combatant` interface with optional `maxHp` and `currentHp`. Add new event types to `events.ts`. - -2. **Domain functions** — Implement `setHp` (set/clear max HP) and `adjustHp` (apply delta to current HP) as pure functions following the existing pattern in `edit-combatant.ts`. - -3. **Domain tests** — Write tests for both domain functions covering acceptance scenarios, invariants, error cases, and edge cases. - -4. **Application use cases** — Create `setHpUseCase` and `adjustHpUseCase` following the existing get-call-save pattern. - -5. **Persistence** — Extend `loadEncounter()` validation to handle optional `maxHp`/`currentHp` fields on combatants. - -6. **Web hook** — Add `setHp` and `adjustHp` callbacks to `useEncounter`. - -7. **UI components** — Add HP controls to combatant rows in `App.tsx`: both Current HP and Max HP inputs always visible (Current HP disabled until Max HP is set), +/- buttons shown only when HP tracking is active, direct current HP entry. - -## Key Files to Modify - -| File | Change | -|------|--------| -| `packages/domain/src/types.ts` | Add optional `maxHp` and `currentHp` to `Combatant` | -| `packages/domain/src/events.ts` | Add `MaxHpSet` and `CurrentHpAdjusted` event types | -| `packages/domain/src/set-hp.ts` | New file: pure function for setting max HP | -| `packages/domain/src/adjust-hp.ts` | New file: pure function for adjusting current HP | -| `packages/domain/src/index.ts` | Re-export new functions | -| `packages/application/src/set-hp-use-case.ts` | New file: set HP use case | -| `packages/application/src/adjust-hp-use-case.ts` | New file: adjust HP use case | -| `packages/application/src/index.ts` | Re-export new use cases | -| `apps/web/src/hooks/use-encounter.ts` | Add setHp/adjustHp callbacks | -| `apps/web/src/persistence/encounter-storage.ts` | Validate HP fields on load | -| `apps/web/src/App.tsx` | HP controls in combatant rows | - -## Development Commands - -```bash -pnpm test # Run all tests -pnpm vitest run packages/domain/src/__tests__/set-hp.test.ts # Single test -pnpm check # Full quality gate -pnpm --filter web dev # Dev server -``` diff --git a/specs/009-combatant-hp/research.md b/specs/009-combatant-hp/research.md deleted file mode 100644 index 3d3754a..0000000 --- a/specs/009-combatant-hp/research.md +++ /dev/null @@ -1,73 +0,0 @@ -# Research: Combatant HP Tracking - -**Feature**: 009-combatant-hp | **Date**: 2026-03-05 - -## Decision 1: Domain Function Granularity - -**Decision**: Two separate domain functions — `setHp` (sets/updates max HP, initializes current HP, syncs full-health combatants) and `adjustHp` (applies a delta to current HP with clamping). - -**Rationale**: The existing codebase follows a one-file-per-operation pattern (`edit-combatant.ts`, `set-initiative.ts`). Separating "set max HP" from "adjust current HP" keeps each function focused and testable. The `adjustHp` function accepting a delta (rather than an absolute value) is extensible: a future damage/heal dialog would call `adjustHp(encounter, id, -15)` for 15 damage, while the current +/- buttons call `adjustHp(encounter, id, -1)` or `adjustHp(encounter, id, +1)`. - -**Alternatives considered**: -- Single `updateHp` function handling both max and current → rejected because it conflates two distinct user intents (configuring a combatant vs. tracking combat damage). -- Separate `damageHp` and `healHp` functions → rejected as premature; `adjustHp` with positive/negative delta covers both directions and is simpler for MVP. - -## Decision 2: HP Field Placement on Combatant - -**Decision**: Add optional `maxHp?: number` and `currentHp?: number` directly to the `Combatant` interface. Both are `undefined` when HP tracking is not active for a combatant. - -**Rationale**: The `Combatant` type is small (id, name, initiative). Adding two optional fields keeps the type flat and simple. The existing persistence layer serializes the entire `Encounter` including all `Combatant` fields, so HP values persist automatically once added to the type. - -**Alternatives considered**: -- Separate `HitPoints` value object → rejected as over-engineering for two fields. Can refactor later if more HP-related fields emerge (temp HP, resistances, etc.). -- HP stored in a separate map keyed by CombatantId → rejected because it breaks the colocation of combatant data and complicates persistence/serialization. - -## Decision 3: Direct HP Entry Implementation - -**Decision**: Direct entry sets `currentHp` to an absolute value (clamped to 0..maxHp). This is handled by `adjustHp` or a simple `setCurrentHp` path within the same function by computing the needed delta internally, or as a separate thin wrapper. Simplest: `adjustHp` accepts a delta, and the UI computes `newValue - currentHp` as the delta. - -**Rationale**: Keeps the domain function consistent (always delta-based, always clamped). The UI adapter is responsible for translating "user typed 35" into a delta. This avoids a third domain function and keeps the domain API small. - -**Alternatives considered**: -- Domain function that accepts absolute current HP → would duplicate clamping logic already in adjustHp. Not chosen. - -## Decision 4: Event Design - -**Decision**: Two event types — `MaxHpSet` (combatantId, previousMaxHp, newMaxHp, previousCurrentHp, newCurrentHp) and `CurrentHpAdjusted` (combatantId, previousHp, newHp, delta). - -**Rationale**: Matches the existing event-per-operation pattern. `MaxHpSet` includes current HP in case it was clamped (the user needs to see that side effect). `CurrentHpAdjusted` includes the delta for future extensibility (a combat log might show "took 5 damage" vs. "healed 3"). - -**Alternatives considered**: -- Single `HpChanged` event → rejected because setting max HP and adjusting current HP are semantically different operations with different triggers. - -## Decision 5: Full-Health Sync on Max HP Change - -**Decision**: When `maxHp` changes and the combatant is at full health (`currentHp === previousMaxHp`), `currentHp` is updated to match the new `maxHp`. When not at full health, `currentHp` is only clamped downward (never increased). - -**Rationale**: A combatant at full health should stay at full health when their max HP increases. This is a clean domain-level rule independent of UI concerns. - -**Alternatives considered**: -- Always sync currentHp to maxHp on change → rejected because it would overwrite intentional HP adjustments (e.g., combatant at 5/20 HP would jump to 25 when max changes to 25). - -## Decision 6: Always-Visible HP Fields + Blur-Commit for Max HP - -**Decision**: Both Current HP and Max HP input fields are always visible in each combatant row. Current HP is disabled when Max HP is not set. The +/- buttons only appear when HP tracking is active (maxHp defined). The Max HP input uses local draft state and commits to the domain only on blur or Enter — not on every keystroke. - -**Rationale**: Two related problems drove this decision: -1. **Focus loss**: Conditionally rendering the Max HP input caused focus loss. Typing the first digit triggered `setHp`, switching DOM branches and destroying the input element. -2. **Premature clearing**: Committing on every keystroke meant clearing the field to retype a value would call `setHp(undefined)`, wiping currentHp. Retyping then triggered the "first set" path, resetting currentHp to the new maxHp — losing the user's previous HP adjustment. - -The blur-commit approach solves both: the input keeps a local draft string, so intermediate states (empty field, partial values) never reach the domain. The domain only sees the final intended value. - -**Alternatives considered**: -- Conditional render with "Set HP" placeholder → rejected because it causes focus loss when the branch switches. -- Per-keystroke commit (matching the initiative input pattern) → rejected because it causes the premature-clearing bug. The initiative input doesn't have this problem since it has no dependent field like currentHp. - -## Decision 7: Persistence Validation - -**Decision**: Extend `loadEncounter()` validation (originally Decision 5) to check `maxHp` and `currentHp` fields on each combatant. If `maxHp` is present, validate it is a positive integer. If `currentHp` is present, validate it is an integer in [0, maxHp]. Missing fields are acceptable (optional). Invalid values cause the field to be stripped (combatant loads without HP rather than failing the entire encounter load). - -**Rationale**: Follows the existing defensive validation pattern in `encounter-storage.ts`. Graceful degradation per-combatant is better than losing the entire encounter. - -**Alternatives considered**: -- Reject entire encounter on HP validation failure → too aggressive; existing combatant data should survive. diff --git a/specs/009-combatant-hp/spec.md b/specs/009-combatant-hp/spec.md deleted file mode 100644 index bdbb21c..0000000 --- a/specs/009-combatant-hp/spec.md +++ /dev/null @@ -1,122 +0,0 @@ -# Feature Specification: Combatant HP Tracking - -**Feature Branch**: `009-combatant-hp` -**Created**: 2026-03-05 -**Status**: Draft -**Input**: User description: "Track max HP and current HP per combatant with quick +/- controls (clamp 0..max); keep the design extensible for later richer damage/heal UI." - -## User Scenarios & Testing - -### User Story 1 - Set Max HP for a Combatant (Priority: P1) - -As a game master, I want to assign a maximum HP value to a combatant so that I can track their health during the encounter. - -**Why this priority**: Max HP is the foundation for all HP tracking. Without it, current HP has no upper bound and the feature has no value. - -**Independent Test**: Can be fully tested by adding a combatant and setting their max HP, then verifying the value is stored and displayed. - -**Acceptance Scenarios**: - -1. **Given** a combatant exists in the encounter, **When** the user sets a max HP value (positive integer), **Then** the combatant's max HP is stored and displayed. -2. **Given** a combatant has no max HP set yet, **When** the combatant is displayed, **Then** both Current HP and Max HP fields are visible (Current HP is disabled until Max HP is set). -3. **Given** a combatant has a max HP of 20, **When** the user changes max HP to 30, **Then** the max HP updates to 30. -4. **Given** a combatant has max HP of 20 and current HP of 20, **When** the user lowers max HP to 15, **Then** current HP is clamped to the new max HP of 15. -5. **Given** a combatant has max HP of 20 and current HP of 20 (full health), **When** the user increases max HP to 30, **Then** current HP increases to 30 (stays at full health). -6. **Given** a combatant has max HP of 20 and current HP of 12 (not full health), **When** the user increases max HP to 30, **Then** current HP remains at 12 (unchanged). - ---- - -### User Story 2 - Quick Adjust Current HP (Priority: P1) - -As a game master, I want to quickly increase or decrease a combatant's current HP using +/- controls so that I can reflect damage and healing during combat without typing exact values. - -**Why this priority**: This is the primary interaction loop during combat -- adjusting HP as damage/healing occurs. Equally critical as setting max HP. - -**Independent Test**: Can be fully tested by setting a combatant's max HP, then using +/- controls and verifying the current HP changes correctly within bounds. - -**Acceptance Scenarios**: - -1. **Given** a combatant has max HP of 20 and current HP of 20, **When** the user presses the "-" control, **Then** current HP decreases by 1 to 19. -2. **Given** a combatant has max HP of 20 and current HP of 15, **When** the user presses the "+" control, **Then** current HP increases by 1 to 16. -3. **Given** a combatant has current HP of 0, **When** the user presses the "-" control, **Then** current HP remains at 0 (clamped to minimum). -4. **Given** a combatant has current HP equal to max HP, **When** the user presses the "+" control, **Then** current HP remains at max HP (clamped to maximum). - ---- - -### User Story 3 - Direct HP Entry (Priority: P2) - -As a game master, I want to type a specific current HP value directly so that I can apply large amounts of damage or healing in one action. - -**Why this priority**: Complements the quick +/- controls for cases where the delta is large. Less critical than basic +/- since the same outcome can be achieved with repeated presses. - -**Independent Test**: Can be fully tested by setting a combatant's max HP, typing a value in the current HP field, and verifying clamping behavior. - -**Acceptance Scenarios**: - -1. **Given** a combatant has max HP of 50, **When** the user types 35 into the current HP field, **Then** current HP is set to 35. -2. **Given** a combatant has max HP of 50, **When** the user types 60 into the current HP field, **Then** current HP is clamped to 50. -3. **Given** a combatant has max HP of 50, **When** the user types -5 into the current HP field, **Then** current HP is clamped to 0. - ---- - -### User Story 4 - HP Persists Across Reloads (Priority: P2) - -As a game master, I want HP values to survive page reloads so that I don't lose health tracking mid-session. - -**Why this priority**: Losing HP data on reload would make the feature unreliable during a game session. Important but builds on existing persistence infrastructure. - -**Independent Test**: Can be tested by setting HP values, reloading the page, and verifying values are restored. - -**Acceptance Scenarios**: - -1. **Given** a combatant has max HP of 30 and current HP of 18, **When** the page is reloaded, **Then** both max HP and current HP are restored. - ---- - -### Edge Cases - -- What happens when max HP is set to 0? System must reject 0 or negative max HP values; max HP must be a positive integer. -- What happens when a combatant is added without HP? The combatant is displayed without HP tracking. HP is optional -- not all combatants need HP (e.g., environmental effects, lair actions). -- What happens when the user enters a non-numeric value for HP? The input is rejected and the previous value is preserved. -- What happens when max HP is cleared/removed? Current HP is also cleared; the combatant returns to the "no HP" state. Clearing only takes effect on blur/Enter (not while typing), so temporarily emptying the field during editing does not wipe current HP. -- What happens when the user selects all text in the max HP field and retypes a new value? The field uses local draft state; the domain only sees the final committed value on blur/Enter. Current HP is preserved. -- What happens when HP is adjusted by 0? The system rejects a zero delta -- no change is made and no event is emitted. - -## Requirements - -### Functional Requirements - -- **FR-001**: Each combatant MAY have an optional max HP value (positive integer). -- **FR-002**: Each combatant with a max HP MUST have a current HP value, defaulting to max HP when first set. -- **FR-003**: Current HP MUST be clamped to the range [0, max HP] at all times. -- **FR-004**: The system MUST provide "+" and "-" controls to adjust current HP by 1. -- **FR-005**: The system MUST allow direct numeric entry of current HP, applying clamping on confirmation. -- **FR-006**: The system MUST allow the user to set and edit the max HP value for any combatant. The max HP value is committed on blur or Enter (not per-keystroke) so that intermediate editing states (e.g., clearing the field to retype) do not affect current HP. -- **FR-007**: When max HP is reduced below current HP, current HP MUST be clamped to the new max HP. -- **FR-011**: When max HP changes and the combatant is at full health (current HP equals previous max HP), current HP MUST stay synced to the new max HP value. -- **FR-012**: When max HP changes and the combatant is NOT at full health, current HP MUST remain unchanged (unless clamped by FR-007). -- **FR-008**: Max HP MUST be a positive integer (>= 1). The system MUST reject zero, negative, or non-integer values. -- **FR-009**: HP values (max and current) MUST persist across page reloads using the existing persistence mechanism. -- **FR-010**: HP tracking MUST be optional per combatant. Combatants without max HP set have no HP display beyond the empty input fields. -- **FR-013**: Both Current HP and Max HP input fields MUST always be visible in each combatant row. The Current HP field is disabled when Max HP is not set. The +/- buttons only appear when HP tracking is active. - -### Key Entities - -- **Combatant** (extended): Gains optional `maxHp` (positive integer) and `currentHp` (integer, 0..maxHp) attributes. When `maxHp` is undefined, the combatant has no HP tracking. - -## Success Criteria - -### Measurable Outcomes - -- **SC-001**: A user can set max HP and adjust current HP for any combatant in under 5 seconds per action. -- **SC-002**: Current HP never exceeds max HP or drops below 0, regardless of user input method. -- **SC-003**: HP values survive a full page reload without data loss. -- **SC-004**: Combatants without HP set display correctly with no HP controls cluttering the interface. - -## Assumptions - -- The increment/decrement step for +/- controls is 1. Larger step sizes (e.g., custom damage amounts) are not in the MVP baseline but the design should not preclude a richer damage/heal UI in the future. -- HP is always an integer (no fractional HP). This aligns with standard tabletop RPG conventions. -- There is no "temporary HP" concept in the MVP baseline. -- There is no death/unconscious state triggered by reaching 0 HP in the MVP baseline. The system simply displays 0. -- The +/- controls and direct entry are the only HP modification methods in the MVP baseline. A richer damage/heal dialog (e.g., entering a damage amount to subtract) is not included but the domain design should be extensible to support it. diff --git a/specs/009-combatant-hp/tasks.md b/specs/009-combatant-hp/tasks.md deleted file mode 100644 index 1b228b5..0000000 --- a/specs/009-combatant-hp/tasks.md +++ /dev/null @@ -1,156 +0,0 @@ -# Tasks: Combatant HP Tracking - -**Input**: Design documents from `/specs/009-combatant-hp/` -**Prerequisites**: plan.md, spec.md, research.md, data-model.md - -**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. - -## Format: `[ID] [P?] [Story] Description` - -- **[P]**: Can run in parallel (different files, no dependencies) -- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) -- Include exact file paths in descriptions - ---- - -## Phase 1: Setup (Shared Infrastructure) - -**Purpose**: Extend domain types and events shared by all user stories - -- [x] T001 Extend `Combatant` interface with optional `maxHp` and `currentHp` fields in `packages/domain/src/types.ts` -- [x] T002 Add `MaxHpSet` and `CurrentHpAdjusted` event types to `DomainEvent` union in `packages/domain/src/events.ts` - ---- - -## Phase 2: Foundational (Blocking Prerequisites) - -**Purpose**: Domain pure functions that all user stories depend on - -**CRITICAL**: No user story work can begin until this phase is complete - -- [x] T003 Implement `setHp` pure function in `packages/domain/src/set-hp.ts` — accepts (Encounter, CombatantId, maxHp: number | undefined), returns {encounter, events} | DomainError. Handles: set new maxHp (currentHp defaults to maxHp), update maxHp (full-health sync: if currentHp === previousMaxHp then currentHp = newMaxHp; otherwise clamp currentHp), clear maxHp (clear both), validate positive integer, combatant-not-found error -- [x] T004 Write tests for `setHp` in `packages/domain/src/__tests__/set-hp.test.ts` — cover acceptance scenarios (set, update, full-health sync on increase, clamp on reduce, clear), invariants (pure, immutable, event shape), error cases (not found, invalid values), edge cases (maxHp=1, reduce below currentHp) -- [x] T005 Implement `adjustHp` pure function in `packages/domain/src/adjust-hp.ts` — accepts (Encounter, CombatantId, delta: number), returns {encounter, events} | DomainError. Clamps result to [0, maxHp]. Errors: combatant-not-found, no-hp-tracking, zero-delta, invalid-delta -- [x] T006 Write tests for `adjustHp` in `packages/domain/src/__tests__/adjust-hp.test.ts` — cover acceptance scenarios (+1, -1, clamp at 0, clamp at max), invariants (pure, immutable, event shape with delta), error cases, edge cases (large delta beyond bounds) -- [x] T007 Re-export `setHp` and `adjustHp` from `packages/domain/src/index.ts` -- [x] T008 [P] Create `setHpUseCase` in `packages/application/src/set-hp-use-case.ts` following get-call-save pattern via `EncounterStore` -- [x] T009 [P] Create `adjustHpUseCase` in `packages/application/src/adjust-hp-use-case.ts` following get-call-save pattern via `EncounterStore` -- [x] T010 Re-export new use cases from `packages/application/src/index.ts` - -**Checkpoint**: Domain and application layers complete. `pnpm test` and `pnpm typecheck` pass. - ---- - -## Phase 3: User Story 1 — Set Max HP for a Combatant (Priority: P1) + User Story 2 — Quick Adjust Current HP (Priority: P1) MVP - -**Goal**: A game master can set max HP on a combatant and use +/- controls to adjust current HP during combat. These two P1 stories are combined because the UI naturally presents them together (max HP input + current HP with +/- buttons in one combatant row). - -**Independent Test**: Add a combatant, set max HP, verify it displays. Press -/+ buttons, verify current HP changes within bounds. Reduce max HP below current HP, verify clamping. - -### Implementation - -- [x] T011 [US1] [US2] Add `setHp` and `adjustHp` callbacks to `useEncounter` hook in `apps/web/src/hooks/use-encounter.ts` — follow existing pattern (call use case, check error, update events) -- [x] T012 [US1] [US2] Add HP controls to combatant rows in `apps/web/src/App.tsx` — both Current HP and Max HP inputs always visible. Current HP input disabled when maxHp is undefined. +/- buttons only shown when HP tracking is active. Max HP input uses local draft state and commits on blur/Enter only (not per-keystroke) to prevent premature clearing of currentHp. Max HP input allows clearing (returning combatant to no-HP state). Clamp visual state matches domain invariants. - -**Checkpoint**: US1 + US2 fully functional. User can set max HP, see current HP, and use +/- buttons. `pnpm check` passes. - ---- - -## Phase 4: User Story 3 — Direct HP Entry (Priority: P2) - -**Goal**: A game master can type a specific current HP value directly instead of using +/- buttons. - -**Independent Test**: Set max HP to 50, type 35 in current HP field, verify it updates. Type 60, verify clamped to 50. Type -5, verify clamped to 0. - -### Implementation - -- [x] T013 [US3] Make current HP display editable (click-to-edit or inline input) in `apps/web/src/App.tsx` — on confirm, compute delta from current value and call `adjustHp`. Apply clamping via domain function. - -**Checkpoint**: US3 functional. Direct numeric entry works alongside +/- controls. `pnpm check` passes. - ---- - -## Phase 5: User Story 4 — HP Persists Across Reloads (Priority: P2) - -**Goal**: HP values survive page reloads via existing localStorage persistence. - -**Independent Test**: Set max HP and adjust current HP, reload the page, verify both values are restored. - -### Implementation - -- [x] T014 [US4] Extend `loadEncounter()` validation in `apps/web/src/persistence/encounter-storage.ts` — validate optional `maxHp` (positive integer) and `currentHp` (integer in [0, maxHp]) on each combatant during deserialization. Strip invalid HP fields per-combatant rather than failing the entire encounter. - -**Checkpoint**: US4 functional. HP values persist across reloads. `pnpm check` passes. - ---- - -## Phase 6: Polish & Cross-Cutting Concerns - -**Purpose**: Final validation and cleanup - -- [x] T015 Run `pnpm check` (knip + format + lint + typecheck + test) and fix any issues -- [x] T016 Verify layer boundary test still passes in `packages/domain/src/__tests__/layer-boundaries.test.ts` - ---- - -## Dependencies & Execution Order - -### Phase Dependencies - -- **Setup (Phase 1)**: No dependencies — start immediately -- **Foundational (Phase 2)**: Depends on Phase 1 completion — BLOCKS all user stories -- **US1+US2 (Phase 3)**: Depends on Phase 2 completion -- **US3 (Phase 4)**: Depends on Phase 3 (extends the HP UI from US1+US2) -- **US4 (Phase 5)**: Can start after Phase 2 (independent of UI work), but naturally follows Phase 3 -- **Polish (Phase 6)**: Depends on all previous phases - -### Within Each Phase - -- T001 and T002 are parallel (different files) -- T003 → T004 (implement then test setHp) -- T005 → T006 (implement then test adjustHp) -- T003 and T005 are parallel (different files, no dependency) -- T008 and T009 are parallel (different files) -- T008/T009 depend on T007 (need exports) -- T011 → T012 (hook before UI) -- T013 depends on T012 (extends existing HP UI) - -### Parallel Opportunities - -```text -Parallel group A (Phase 1): T001 || T002 -Parallel group B (Phase 2): T003+T004 || T005+T006 (then T007, then T008 || T009, then T010) -Sequential (Phase 3): T011 → T012 -Sequential (Phase 4): T013 -Independent (Phase 5): T014 -``` - ---- - -## Implementation Strategy - -### MVP First (US1 + US2) - -1. Complete Phase 1: Setup (types + events) -2. Complete Phase 2: Foundational (domain functions + use cases) -3. Complete Phase 3: US1 + US2 (set max HP + quick adjust) -4. **STOP and VALIDATE**: Can set HP and use +/- controls -5. Continue with US3 (direct entry) and US4 (persistence) - -### Incremental Delivery - -1. Phase 1 + 2 → Domain + application ready -2. Phase 3 → MVP: max HP + quick adjust functional -3. Phase 4 → Direct HP entry added -4. Phase 5 → Persistence extended -5. Phase 6 → Quality gate passes, ready to merge - ---- - -## Notes - -- [P] tasks = different files, no dependencies -- [Story] label maps task to specific user story for traceability -- Commit after each phase checkpoint -- US1 and US2 are combined in Phase 3 because they share UI surface (HP controls on combatant row) -- Domain functions are designed for extensibility: `adjustHp` accepts any integer delta, so a future damage/heal dialog can call it directly diff --git a/specs/010-ui-baseline/checklists/requirements.md b/specs/010-ui-baseline/checklists/requirements.md deleted file mode 100644 index e2d0ac3..0000000 --- a/specs/010-ui-baseline/checklists/requirements.md +++ /dev/null @@ -1,36 +0,0 @@ -# Specification Quality Checklist: UI Baseline - -**Purpose**: Validate specification completeness and quality before proceeding to planning -**Created**: 2026-03-05 -**Feature**: [spec.md](../spec.md) - -## Content Quality - -- [x] No implementation details (languages, frameworks, APIs) -- [x] Focused on user value and business needs -- [x] Written for non-technical stakeholders -- [x] All mandatory sections completed - -## Requirement Completeness - -- [x] No [NEEDS CLARIFICATION] markers remain -- [x] Requirements are testable and unambiguous -- [x] Success criteria are measurable -- [x] Success criteria are technology-agnostic (no implementation details) -- [x] All acceptance scenarios are defined -- [x] Edge cases are identified -- [x] Scope is clearly bounded -- [x] Dependencies and assumptions identified - -## Feature Readiness - -- [x] All functional requirements have clear acceptance criteria -- [x] User scenarios cover primary flows -- [x] Feature meets measurable outcomes defined in Success Criteria -- [x] No implementation details leak into specification - -## Notes - -- Technology choices (Tailwind, shadcn/ui) are mentioned only in the Assumptions section as adapter-layer decisions, not in requirements or success criteria. -- All 11 functional requirements are testable through visual inspection of the rendered UI. -- No clarification markers needed — the feature description was detailed and scope is well-bounded (UI-only, no domain changes). diff --git a/specs/010-ui-baseline/contracts/ui-components.md b/specs/010-ui-baseline/contracts/ui-components.md deleted file mode 100644 index cbfaa62..0000000 --- a/specs/010-ui-baseline/contracts/ui-components.md +++ /dev/null @@ -1,78 +0,0 @@ -# UI Component Contracts: UI Baseline - -**Feature**: 010-ui-baseline | **Date**: 2026-03-05 - -## Layout Contract - -The encounter screen follows a single-column layout with three zones: - -``` -┌─────────────────────────────────┐ -│ EncounterHeader │ -│ Title + Round/Turn status │ -├─────────────────────────────────┤ -│ CombatantList │ -│ ┌─ CombatantRow (active) ───┐ │ -│ │ Init │ Name │ HP │ Actions│ │ -│ └───────────────────────────┘ │ -│ ┌─ CombatantRow ────────────┐ │ -│ │ Init │ Name │ HP │ Actions│ │ -│ └───────────────────────────┘ │ -│ ... (scrollable if overflow) │ -│ │ -│ [EmptyState if no combatants] │ -├─────────────────────────────────┤ -│ ActionBar │ -│ [Name input] [Add] [Next Turn] │ -└─────────────────────────────────┘ -``` - -## CombatantRow Contract - -**Props**: -- `combatant: Combatant` — domain entity -- `isActive: boolean` — whether this is the active turn -- `onRename: (id, newName) => void` -- `onSetInitiative: (id, value) => void` -- `onRemove: (id) => void` -- `onSetHp: (id, maxHp) => void` -- `onAdjustHp: (id, delta) => void` - -**Visual contract**: -- Row uses consistent column widths across all combatant rows -- Active row has visually distinct highlight (accent background or left border) -- Name column truncates with ellipsis at max width -- Remove action is an icon button (no text label) -- All inputs use design system styling (no browser defaults) - -**Interaction contract**: -- Click name → enter inline edit mode -- Enter/blur in edit mode → commit change -- Escape in edit mode → cancel -- Initiative input is always visible (not click-to-edit), direct typing only -- HP inputs are direct-entry text fields with numeric keyboard (`inputmode="numeric"`) -- All numeric inputs: no browser spinners, ch-based widths (`6ch`), tabular numerals, centered text - -## ActionBar Contract - -**Props**: -- `onAddCombatant: (name: string) => void` -- `onAdvanceTurn: () => void` - -**Visual contract**: -- Visually separated from combatant list (spacing, background, or border) -- Add form: text input + submit button in a row -- Next Turn: distinct button, visually secondary to Add - -**Interaction contract**: -- Enter in name input → add combatant + clear input -- Empty name → no action (button may be disabled or form simply ignores) - -## EmptyState Contract - -**Displayed when**: `encounter.combatants.length === 0` - -**Visual contract**: -- Centered message in the combatant list area -- Muted/secondary text color -- Suggests adding a combatant diff --git a/specs/010-ui-baseline/data-model.md b/specs/010-ui-baseline/data-model.md deleted file mode 100644 index 20a3397..0000000 --- a/specs/010-ui-baseline/data-model.md +++ /dev/null @@ -1,75 +0,0 @@ -# Data Model: UI Baseline - -**Feature**: 010-ui-baseline | **Date**: 2026-03-05 - -## Domain Entities (UNCHANGED) - -This feature makes no domain changes. The existing domain types are consumed as-is by the UI layer. - -### Combatant (read-only from UI perspective) - -| Field | Type | Notes | -|-------|------|-------| -| id | CombatantId (branded string) | Unique identifier | -| name | string | Display name, editable inline | -| initiative | number \| undefined | Sort order, editable inline | -| maxHp | number \| undefined | Maximum hit points | -| currentHp | number \| undefined | Current hit points (present when maxHp is set) | - -### Encounter (read-only from UI perspective) - -| Field | Type | Notes | -|-------|------|-------| -| combatants | readonly Combatant[] | Sorted by initiative descending | -| activeIndex | number | Index of current turn's combatant | -| roundNumber | number | Current round counter | - -## UI Component Model (NEW) - -These are adapter-layer visual components — not domain entities. - -### CombatantRow - -Renders a single combatant as a structured row with consistent column alignment. - -| Column | Content | Behavior | -|--------|---------|----------| -| Initiative | Number or placeholder | Text input with `inputmode="numeric"`, no spinners, direct typing only | -| Name | Combatant name | Inline editable, click-to-edit. Truncates with ellipsis | -| HP | currentHp / maxHp | Text inputs with `inputmode="numeric"`, no spinners, direct entry for both values | -| Actions | Remove icon button | Compact icon (X or trash), tooltip on hover | - -**Numeric field styling**: All numeric inputs use `type="text"` with `inputmode="numeric"` to suppress browser spinners while showing the numeric keyboard on mobile. Initiative uses `6ch` width (1–2 digit values typical). HP fields use `7ch` width (supports up to 4-digit values like 1000). All use tabular numerals (`font-variant-numeric: tabular-nums`) for column alignment and centered text. - -**Visual states**: -- Default row -- Active row (highlighted background/border for current turn) -- Editing state (inline input replaces display text) - -### ActionBar - -Groups primary encounter controls. - -| Element | Type | Behavior | -|---------|------|----------| -| Name input | Text input | Enter combatant name | -| Add button | Primary button | Adds combatant to encounter | -| Next Turn button | Secondary/outline button | Advances to next combatant | - -### EncounterHeader - -Displays encounter status. - -| Element | Content | -|---------|---------| -| Title | "Initiative Tracker" | -| Status | Round number and active combatant name | - -### EmptyState - -Displayed when combatants list is empty. - -| Element | Content | -|---------|---------| -| Message | "No combatants yet" or similar | -| Hint | Prompt to add first combatant | diff --git a/specs/010-ui-baseline/plan.md b/specs/010-ui-baseline/plan.md deleted file mode 100644 index e4bf023..0000000 --- a/specs/010-ui-baseline/plan.md +++ /dev/null @@ -1,75 +0,0 @@ -# Implementation Plan: UI Baseline - -**Branch**: `010-ui-baseline` | **Date**: 2026-03-05 | **Spec**: [spec.md](./spec.md) -**Input**: Feature specification from `/specs/010-ui-baseline/spec.md` - -## Summary - -Establish a modern UI baseline for the encounter screen by integrating Tailwind CSS v4 and shadcn/ui into the existing Vite + React 19 web app. Replace all unstyled HTML with a consistent design system: structured combatant rows with aligned columns, active turn highlight, grouped action bar, icon remove buttons, and consistent typography. No domain or application layer changes — all work is in the `apps/web` adapter layer. - -## Technical Context - -**Language/Version**: TypeScript 5.8 (strict mode, verbatimModuleSyntax) -**Primary Dependencies**: React 19, Vite 6, Tailwind CSS v4, shadcn/ui, Lucide React (icons) -**Storage**: N/A (no storage changes — localStorage persistence unchanged) -**Testing**: Vitest (existing layer boundary tests must pass; no new visual tests in MVP baseline) -**Target Platform**: Modern browsers (desktop-first, not broken on mobile) -**Project Type**: Web application (monorepo: apps/web + packages/domain + packages/application) -**Performance Goals**: No perceptible rendering delay; encounter screen usable during live tabletop play -**Constraints**: UI-only changes; domain and application layers untouched -**Scale/Scope**: Single screen (encounter tracker), ~6 component areas to restyle - -## Constitution Check - -*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* - -| Principle | Status | Notes | -|-----------|--------|-------| -| I. Deterministic Domain Core | PASS | No domain changes | -| II. Layered Architecture | PASS | All changes in adapter layer (apps/web) only | -| III. Agent Boundary | N/A | No agent features involved | -| IV. Clarification-First | PASS | Spec is fully specified, no ambiguities | -| V. Escalation Gates | PASS | Feature stays within spec scope | -| VI. MVP Baseline Language | PASS | Dark theme, full responsive noted as "not in MVP baseline" | -| VII. No Gameplay Rules | PASS | No gameplay logic involved | - -## Project Structure - -### Documentation (this feature) - -```text -specs/010-ui-baseline/ -├── plan.md # This file -├── research.md # Phase 0: Tailwind v4 + shadcn/ui setup research -├── data-model.md # Phase 1: UI component model (no domain changes) -├── quickstart.md # Phase 1: Developer quickstart -├── contracts/ # Phase 1: UI component contracts -│ └── ui-components.md -└── tasks.md # Phase 2 output (created by /speckit.tasks) -``` - -### Source Code (repository root) - -```text -apps/web/ -├── src/ -│ ├── main.tsx # Add global CSS import -│ ├── index.css # NEW: Tailwind directives + CSS variables -│ ├── lib/ -│ │ └── utils.ts # NEW: cn() helper (clsx + twMerge) -│ ├── components/ -│ │ └── ui/ # NEW: shadcn/ui primitives (Button, Input, Card, etc.) -│ ├── App.tsx # Refactored: use new components + Tailwind classes -│ └── hooks/ -│ └── use-encounter.ts # UNCHANGED -└── package.json # Updated: new dependencies - -packages/domain/ # UNCHANGED -packages/application/ # UNCHANGED -``` - -**Structure Decision**: Follows existing monorepo layout. New UI components live in `apps/web/src/components/ui/` following shadcn/ui convention. No new packages or layers introduced. - -## Complexity Tracking - -No constitution violations. No complexity justifications needed. diff --git a/specs/010-ui-baseline/quickstart.md b/specs/010-ui-baseline/quickstart.md deleted file mode 100644 index c2251e9..0000000 --- a/specs/010-ui-baseline/quickstart.md +++ /dev/null @@ -1,62 +0,0 @@ -# Quickstart: UI Baseline - -**Feature**: 010-ui-baseline | **Date**: 2026-03-05 - -## Prerequisites - -- Node.js 18+ -- pnpm 10.6+ - -## Setup - -```bash -# Install dependencies (from repo root) -pnpm install - -# Start dev server -pnpm --filter web dev -# → http://localhost:5173 -``` - -## New Dependencies (to be added) - -```bash -# Tailwind CSS v4 with Vite plugin -pnpm --filter web add tailwindcss @tailwindcss/vite - -# shadcn/ui utilities -pnpm --filter web add clsx tailwind-merge class-variance-authority - -# Radix UI primitives (used by shadcn/ui components) -pnpm --filter web add @radix-ui/react-slot @radix-ui/react-tooltip - -# Icons -pnpm --filter web add lucide-react -``` - -## Key Files - -| File | Purpose | -|------|---------| -| `apps/web/src/index.css` | Tailwind directives + CSS custom properties | -| `apps/web/src/lib/utils.ts` | `cn()` class merge utility | -| `apps/web/src/components/ui/` | shadcn/ui primitives (Button, Input, etc.) | -| `apps/web/src/components/combatant-row.tsx` | Combatant row component | -| `apps/web/src/App.tsx` | Main layout (header, list, action bar) | -| `apps/web/vite.config.ts` | Updated with Tailwind plugin | - -## Quality Gate - -```bash -# Must pass before commit -pnpm check -``` - -This runs: knip → format → lint → typecheck → test - -## Design System - -- **Styling**: Tailwind CSS v4 utility classes -- **Components**: shadcn/ui (copied into project, not a package dependency) -- **Icons**: Lucide React -- **Class merging**: `cn()` from `lib/utils.ts` (clsx + tailwind-merge) diff --git a/specs/010-ui-baseline/research.md b/specs/010-ui-baseline/research.md deleted file mode 100644 index 4844cbd..0000000 --- a/specs/010-ui-baseline/research.md +++ /dev/null @@ -1,72 +0,0 @@ -# Research: UI Baseline - -**Feature**: 010-ui-baseline | **Date**: 2026-03-05 - -## R1: Tailwind CSS Version Choice - -**Decision**: Use Tailwind CSS v4 (latest stable) -**Rationale**: Tailwind v4 uses a CSS-first configuration approach with `@import "tailwindcss"` and CSS-based theme customization via `@theme`. This simplifies setup — no `tailwind.config.ts` is strictly required for basic usage. Vite has first-class support via `@tailwindcss/vite` plugin. -**Alternatives considered**: -- Tailwind v3: Mature but requires JS config file and PostCSS plugin. v4 is stable and recommended for new projects. - -## R2: shadcn/ui Integration Approach - -**Decision**: Use shadcn/ui CLI to scaffold components into `apps/web/src/components/ui/` -**Rationale**: shadcn/ui is not a package dependency — it copies component source code into the project. This gives full control over styling and avoids version lock-in. Components use Tailwind classes + Radix UI primitives. -**Alternatives considered**: -- Radix UI directly: More low-level, requires writing all styles manually. shadcn/ui provides pre-styled Tailwind components. -- Material UI / Chakra UI: Heavier runtime, opinionated styling that conflicts with Tailwind approach. - -## R3: Tailwind v4 + Vite Setup - -**Decision**: Use `@tailwindcss/vite` plugin instead of PostCSS -**Rationale**: Tailwind v4 provides a dedicated Vite plugin that is faster than the PostCSS approach. Setup is: -1. Install `tailwindcss @tailwindcss/vite` -2. Add plugin to `vite.config.ts` -3. Create `index.css` with `@import "tailwindcss"` -4. Import `index.css` in `main.tsx` -**Alternatives considered**: -- PostCSS plugin: Works but slower; Vite plugin is recommended for Vite projects. - -## R4: Icon Library - -**Decision**: Use Lucide React for icons (remove button, etc.) -**Rationale**: Lucide is the default icon set for shadcn/ui. Tree-shakeable, consistent style, and already expected by shadcn/ui component templates. -**Alternatives considered**: -- Heroicons: Good quality but not the shadcn/ui default. -- Inline SVG: Too manual for maintenance. - -## R5: CSS Utility Helper (cn function) - -**Decision**: Use `clsx` + `tailwind-merge` via a `cn()` utility function -**Rationale**: Standard shadcn/ui pattern. `clsx` handles conditional classes, `tailwind-merge` deduplicates conflicting Tailwind classes. The `cn()` helper is placed in `lib/utils.ts`. -**Alternatives considered**: -- `clsx` alone: Doesn't deduplicate conflicting Tailwind classes (e.g., `p-2 p-4`). - -## R6: Component Decomposition - -**Decision**: Extract App.tsx into focused components while keeping them in a single file or minimal files -**Rationale**: The current App.tsx (~280 lines) has inline components (EditableName, MaxHpInput, CurrentHpInput). For the UI baseline, we'll restructure into: -- `App.tsx` — layout shell (header, combatant list, action bar) -- `components/combatant-row.tsx` — single combatant row with all controls -- `components/ui/` — shadcn/ui primitives (Button, Input, Card) - -This keeps the change focused while establishing a scalable component structure. -**Alternatives considered**: -- Keep everything in App.tsx: Gets unwieldy with Tailwind classes added. -- Full atomic decomposition: Over-engineered for current scope. - -## R7: verbatimModuleSyntax Compatibility - -**Decision**: shadcn/ui components work with `verbatimModuleSyntax` since they use standard ESM imports -**Rationale**: shadcn/ui generates standard TypeScript files with explicit type imports. The `cn` utility and Radix imports use value imports. No special handling needed. - -## R8: Biome 2.0 Compatibility - -**Decision**: No conflicts expected; Tailwind class strings are just strings -**Rationale**: Biome formats/lints TypeScript and JSX. Tailwind classes in `className` props are plain strings, which Biome ignores content-wise. The shadcn/ui generated code follows standard formatting conventions. May need to run `pnpm format` after generating components. - -## R9: Knip Compatibility - -**Decision**: May need to configure Knip to recognize shadcn/ui component exports -**Rationale**: shadcn/ui components are copied into the project. If not all are immediately used, Knip may flag them as unused. Solution: only install the shadcn/ui components we actually need (Button, Input, Card/container). diff --git a/specs/010-ui-baseline/spec.md b/specs/010-ui-baseline/spec.md deleted file mode 100644 index 1ac26e6..0000000 --- a/specs/010-ui-baseline/spec.md +++ /dev/null @@ -1,140 +0,0 @@ -# Feature Specification: UI Baseline - -**Feature Branch**: `010-ui-baseline` -**Created**: 2026-03-05 -**Status**: Draft -**Input**: User description: "Establish a modern UI baseline for the encounter screen using Tailwind + shadcn/ui." - -## User Scenarios & Testing *(mandatory)* - -### User Story 1 - Structured Combatant Layout (Priority: P1) - -As a game master viewing the encounter screen, I see each combatant displayed as a structured row with initiative, name, HP, and actions aligned in consistent columns, so I can quickly scan the battlefield state. - -**Why this priority**: The core value of a UI baseline is replacing the unstyled list with a consistent, scannable layout. Every other visual improvement builds on this. - -**Independent Test**: Can be fully tested by adding 3+ combatants and verifying that all data fields (initiative, name, HP, actions) are visually aligned in a grid/table-like layout. - -**Acceptance Scenarios**: - -1. **Given** an encounter with multiple combatants, **When** the screen loads, **Then** each combatant is displayed as a row with initiative, name, HP, and actions in consistent columns. -2. **Given** combatants with varying name lengths, **When** displayed, **Then** columns remain aligned and do not shift or overlap. -3. **Given** a combatant with no initiative or HP set, **When** displayed, **Then** placeholder or empty state is shown in the appropriate column without breaking alignment. - ---- - -### User Story 2 - Active Combatant Highlight (Priority: P1) - -As a game master during combat, I see the active combatant clearly highlighted so I can instantly identify whose turn it is without reading text markers. - -**Why this priority**: Identifying the active turn is the primary interaction during live play. A clear visual highlight is essential for usability. - -**Independent Test**: Can be tested by advancing turns and verifying the active combatant has a distinct visual treatment (background color, border, or similar). - -**Acceptance Scenarios**: - -1. **Given** an encounter in progress, **When** viewing the combatant list, **Then** the active combatant's row has a visually distinct highlight (e.g., accent background, left border indicator). -2. **Given** the turn advances, **When** a new combatant becomes active, **Then** the highlight moves to the new active combatant and is removed from the previous one. - ---- - -### User Story 3 - Grouped Action Bar (Priority: P2) - -As a game master, I see primary encounter controls (add combatant, next turn) grouped in a clearly defined action bar, so controls are easy to find and visually separated from the combatant list. - -**Why this priority**: Grouping controls improves discoverability and reduces visual clutter, but is less critical than the combatant layout itself. - -**Independent Test**: Can be tested by verifying that the "Add Combatant" form and "Next Turn" button are visually grouped in a distinct bar area, separated from the combatant list. - -**Acceptance Scenarios**: - -1. **Given** the encounter screen, **When** viewing controls, **Then** the "Add Combatant" input and "Next Turn" button are grouped in a visually distinct action bar. -2. **Given** the action bar, **When** inspecting layout, **Then** it is clearly separated from the combatant list by spacing, background, or border. - ---- - -### User Story 4 - Inline Editing with Consistent Styling (Priority: P2) - -As a game master, I can click on a combatant's name to edit it inline, and all editable controls (including initiative and HP inputs) match the overall visual style of the application. - -**Why this priority**: Inline editing already exists functionally. This story ensures the edit states are visually consistent with the new design system rather than appearing as unstyled browser defaults. - -**Independent Test**: Can be tested by clicking a combatant name to enter edit mode and verifying the input field matches the application's visual style. - -**Acceptance Scenarios**: - -1. **Given** a combatant row, **When** I click the name, **Then** it transitions to an inline text input styled consistently with the design system. -2. **Given** a combatant row, **When** I edit initiative or HP values, **Then** the number inputs are styled consistently with the design system. - ---- - -### User Story 5 - Remove Action as Icon Button (Priority: P3) - -As a game master, I see the remove action as a small icon button rather than a full text button, so it takes up less space and reduces visual noise. - -**Why this priority**: This is a refinement that improves information density. The remove action is infrequently used, so a compact icon button is appropriate. - -**Independent Test**: Can be tested by verifying each combatant row has a small icon-sized remove button (e.g., trash or X icon) instead of a text "Remove" button. - -**Acceptance Scenarios**: - -1. **Given** a combatant row, **When** viewing the actions column, **Then** the remove action is displayed as a small icon button (not a text label). -2. **Given** the icon button, **When** hovering, **Then** a tooltip or visual feedback indicates the action is "Remove". - ---- - -### User Story 6 - Consistent Typography and Spacing (Priority: P2) - -As a game master, the encounter screen uses consistent font sizes, weights, and spacing throughout, creating a cohesive and professional appearance. - -**Why this priority**: Typography and spacing consistency is foundational to a "modern UI baseline" and affects the perceived quality of every other element. - -**Independent Test**: Can be tested by verifying that heading sizes, body text, input text, and spacing follow a consistent scale across the entire screen. - -**Acceptance Scenarios**: - -1. **Given** the encounter screen, **When** inspecting typography, **Then** headings, labels, and body text use a consistent type scale. -2. **Given** the encounter screen, **When** inspecting spacing, **Then** padding and margins follow a consistent spacing scale. - ---- - -### Edge Cases - -- What happens when no combatants exist? The screen should display an empty state message (e.g., "No combatants yet") rather than a blank area. -- What happens with very long combatant names? Names should truncate with ellipsis rather than breaking the layout. -- What happens on narrow viewports? The layout should remain usable, though a fully responsive mobile design is not in the MVP baseline for this feature. -- What happens with 20+ combatants? The list should scroll without the action bar or header disappearing. - -## Requirements *(mandatory)* - -### Functional Requirements - -- **FR-001**: System MUST display combatants in a structured row layout with columns for initiative, name, HP (current/max), and actions. All numeric fields use text inputs with `inputmode="numeric"` (no browser spinners), ch-based widths, tabular numerals, and centered text. -- **FR-002**: System MUST visually highlight the active combatant's row with a distinct background, border, or accent treatment. -- **FR-003**: System MUST group the "Add Combatant" form and "Next Turn" button in a visually distinct action bar area. -- **FR-004**: System MUST display the remove action as a compact icon button (not a text label) on each combatant row. -- **FR-005**: System MUST style all form inputs (text fields, number inputs) consistently using the design system's components. -- **FR-006**: System MUST apply consistent typography (font family, sizes, weights) and spacing (margins, padding) from a defined scale. -- **FR-007**: System MUST show a round/turn status indicator (current round number and active combatant name) in a header or status area. -- **FR-008**: System MUST display an empty state message when no combatants are present. -- **FR-009**: System MUST truncate long combatant names with ellipsis rather than breaking the layout. -- **FR-010**: System MUST NOT introduce any domain logic changes; all changes are confined to the adapter/UI layer. -- **FR-011**: System MUST NOT display domain events in the main UI layout. - -## Success Criteria *(mandatory)* - -### Measurable Outcomes - -- **SC-001**: All combatant data fields (initiative, name, HP, actions) are visually aligned in consistent columns across all combatant rows. -- **SC-002**: The active combatant is identifiable within 1 second of glancing at the screen, without reading text labels. -- **SC-003**: A new user can locate the "Add Combatant" and "Next Turn" controls within 3 seconds of viewing the screen. -- **SC-004**: All interactive elements (buttons, inputs) are styled consistently with no browser-default styled controls visible. -- **SC-005**: The encounter screen presents a cohesive visual appearance with no mismatched fonts, inconsistent spacing, or unstyled elements. - -## Assumptions - -- Tailwind CSS and shadcn/ui will be used as the design system and component library. These are adapter-layer technology choices. -- The existing functional behavior (inline editing, HP controls, add/remove/advance) is preserved exactly; only visual presentation changes. -- A dark theme or theme toggle is not included in the MVP baseline for this feature. -- Full mobile/responsive optimization is not included in the MVP baseline for this feature, though the layout should not be broken on smaller screens. -- Domain events will be removed from the main UI display (they were a development aid, not a user-facing feature). diff --git a/specs/010-ui-baseline/tasks.md b/specs/010-ui-baseline/tasks.md deleted file mode 100644 index 80586cc..0000000 --- a/specs/010-ui-baseline/tasks.md +++ /dev/null @@ -1,207 +0,0 @@ -# Tasks: UI Baseline - -**Input**: Design documents from `/specs/010-ui-baseline/` -**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/ui-components.md - -**Tests**: No new tests requested in spec. Existing tests (layer boundary checks) must continue to pass. - -**Organization**: Tasks grouped by user story. US1+US2 are combined (both P1, same component). - -## Format: `[ID] [P?] [Story] Description` - -- **[P]**: Can run in parallel (different files, no dependencies) -- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) -- Include exact file paths in descriptions - -## Phase 1: Setup (Shared Infrastructure) - -**Purpose**: Install Tailwind CSS v4, shadcn/ui utilities, and configure the build pipeline - -- [x] T001 Install `tailwindcss` and `@tailwindcss/vite` as devDependencies and add the Tailwind Vite plugin to `apps/web/vite.config.ts` -- [x] T002 [P] Create `apps/web/src/index.css` with `@import "tailwindcss"` directive and `@theme` block defining CSS custom properties for the design system (colors, radii, fonts) -- [x] T003 [P] Install `clsx` and `tailwind-merge` as dependencies in `apps/web` and create the `cn()` utility in `apps/web/src/lib/utils.ts` -- [x] T004 Import `./index.css` in `apps/web/src/main.tsx` -- [x] T005 [P] Install `lucide-react` as a dependency in `apps/web` - ---- - -## Phase 2: Foundational (Blocking Prerequisites) - -**Purpose**: Create shadcn/ui-style primitive components that all user stories depend on - -**Warning**: No user story work can begin until this phase is complete - -- [x] T006 [P] Install `class-variance-authority` as a dependency in `apps/web` and create Button component in `apps/web/src/components/ui/button.tsx` following shadcn/ui pattern (variant props: default, outline, ghost, icon; size props: default, sm, icon) using `cn()` and `class-variance-authority` -- [x] T007 [P] Create Input component in `apps/web/src/components/ui/input.tsx` following shadcn/ui pattern (styled text/number input replacing browser defaults) using `cn()` - -**Note**: T008 merged into T006 (install CVA + create Button in one task). - -**Checkpoint**: Foundation ready — shadcn/ui primitives available, Tailwind active - ---- - -## Phase 3: User Story 1 + 2 — Structured Layout + Active Highlight (Priority: P1) MVP - -**Goal**: Replace the unstyled `
    /
  • ` combatant list with a structured row layout (initiative | name | HP | actions columns) and visually highlight the active combatant's row - -**Independent Test**: Add 3+ combatants, verify columns are aligned. Set initiative and HP on some. Advance turns and confirm the active row has a distinct highlight that moves correctly. - -### Implementation - -- [x] T009 [US1] Extract `CombatantRow` component to `apps/web/src/components/combatant-row.tsx` — accepts `combatant`, `isActive`, and action callbacks per the UI contract. Render a grid/flex row with four columns: initiative (number input), name (click-to-edit text), HP (current/max with +/- buttons), actions (remove button placeholder). Apply active row highlight (accent left border + subtle background) when `isActive` is true. Move `EditableName`, `MaxHpInput`, and `CurrentHpInput` inline components into this file. -- [x] T010 [US1] Refactor `apps/web/src/App.tsx` to use `CombatantRow` — replace the `
      ` list with a styled container. Add encounter header section showing title ("Initiative Tracker") and round/turn status. Remove domain events display section entirely (FR-011). Keep `useEncounter` hook usage and all callbacks wired through. - -**Checkpoint**: Combatants display in aligned columns with active highlight. All existing functionality preserved. - ---- - -## Phase 4: User Story 3 — Grouped Action Bar (Priority: P2) - -**Goal**: Group the "Add Combatant" form and "Next Turn" button into a visually distinct action bar separated from the combatant list - -**Independent Test**: Verify controls are grouped in a distinct bar area with visual separation (background, border, or spacing) from the combatant list. - -### Implementation - -- [x] T011 [US3] Extract `ActionBar` component to `apps/web/src/components/action-bar.tsx` — accepts `onAddCombatant` and `onAdvanceTurn` callbacks. Render a styled bar with the add-combatant form (Input + Button) and Next Turn button (outline/secondary variant). Apply visual separation from the combatant list. -- [x] T012 [US3] Update `apps/web/src/App.tsx` to use `ActionBar` component — replace inline form and button with the extracted component. - -**Checkpoint**: Action bar is visually grouped and separated from combatant list. - ---- - -## Phase 5: User Story 4 — Inline Editing with Consistent Styling (Priority: P2) - -**Goal**: Ensure all inline edit states (name, initiative, HP inputs) use the design system Input component instead of unstyled browser defaults - -**Independent Test**: Click a combatant name to edit — the input should match the design system style. Verify initiative and HP number inputs are also styled consistently. - -### Implementation - -- [x] T013 [US4] Update `EditableName`, `MaxHpInput`, `CurrentHpInput`, and initiative input in `apps/web/src/components/combatant-row.tsx` to use the shadcn/ui-style `Input` component from `components/ui/input.tsx`. Ensure edit-mode inputs match display-mode styling for seamless transitions. - -**Checkpoint**: All form inputs across the encounter screen use consistent design system styling. - ---- - -## Phase 6: User Story 5 — Remove Action as Icon Button (Priority: P3) - -**Goal**: Replace the text "Remove" button with a compact icon button using a Lucide icon - -**Independent Test**: Each combatant row shows a small icon button (X or Trash2) instead of a text "Remove" button. Hovering shows tooltip feedback. - -### Implementation - -- [x] T014 [US5] Replace the remove `