26 Commits

Author SHA1 Message Date
Lukas
369feb3cc8 Add deploy step to CI workflow to auto-restart container on tag push
All checks were successful
CI / check (push) Successful in 43s
CI / build-image (push) Successful in 17s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:19:34 +01:00
Lukas
51bdb799ae Skip lifecycle scripts in Docker build to avoid missing git
All checks were successful
CI / check (push) Successful in 47s
CI / build-image (push) Successful in 29s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:06:49 +01:00
Lukas
1baddad939 Remove pnpm cache from setup-node to fix Gitea Actions timeout
Some checks failed
CI / check (push) Successful in 47s
CI / build-image (push) Failing after 18s
2026-03-12 09:52:16 +01:00
Lukas
e701e4dd70 Run build-image job on host to access Docker CLI
Some checks failed
CI / check (push) Failing after 14m13s
CI / build-image (push) Has been cancelled
The node:22 container doesn't have Docker installed. Running
on the host label executes directly on the VPS where Docker
is available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:18:51 +01:00
Lukas
e2b0e7d5ee Exclude .pnpm-store from Biome and add .dockerignore
Some checks failed
CI / check (push) Successful in 10m21s
CI / build-image (push) Failing after 4s
The CI runner's pnpm store lands inside the workspace, causing
Biome to lint/format hundreds of store index JSON files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:46:38 +01:00
Lukas
635e9c0705 Add Dockerfile, nginx config, and Gitea Actions CI workflow
Some checks failed
CI / check (push) Failing after 6m20s
CI / build-image (push) Has been skipped
Multi-stage Docker build produces an Nginx container serving the
static SPA. The CI workflow runs pnpm check on every push and
builds/pushes a Docker image on semver tags.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:32:12 +01:00
Lukas
582a42e62d Change add creature placeholder to "+ Add combatants"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:50:09 +01:00
Lukas
fc43f440aa Toggle pin off when clicking pin on already-pinned creature
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:49:00 +01:00
Lukas
1cf30b3622 Add swipe-to-dismiss gesture for mobile stat block drawer
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:48:48 +01:00
Lukas
2ce0ff50b9 Always show add-condition and delete buttons on touch devices
Use a pointer-coarse custom variant to bypass hover-dependent
visibility on devices without a precise pointer (phones, tablets).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:37:04 +01:00
Lukas
96a7b2d00e Remove pencil icon, use cursor-text to signal editable name
The hover-revealed pencil icon caused layout shift on rows with
conditions. Modern UIs (Figma, Notion, Linear) rely on double-click
without a visible edit icon. Replace with cursor-text on hover.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:32:56 +01:00
Lukas
2d8e823eff Replace single-click rename with double-click, pencil icon, and long-press (#6)
Single-clicking a combatant name now opens the stat block panel instead of
entering edit mode. Renaming is triggered by double-click, a hover pencil
icon, or long-press on touch. Also fixes condition picker positioning when
near viewport edges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:22:21 +01:00
Lukas
613bb70065 Consolidate 36 per-change specs into 4 feature-level specs and align workflow
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 <noreply@anthropic.com>
2026-03-11 21:33:27 +01:00
Lukas
b6e052f198 Overhaul bottom bar: batch add, custom fields, stat block viewer
Unify the action bar into a single search input with inline bestiary
dropdown. Clicking a dropdown entry queues it with +/- count controls
and a confirm button; Enter or confirm adds N copies to combat.

When no bestiary match exists, optional Init/AC/MaxHP fields appear
for custom creatures. The eye icon opens a separate search dropdown
to preview stat blocks without leaving the add flow.

Fix batch-add bug where only the last creature got a creatureId by
using store.save() instead of setEncounter() in addFromBestiary.
Prevent dropdown buttons from stealing input focus so Enter confirms
the queued batch.

Remove the now-redundant BestiarySearch component.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:27:06 +01:00
Lukas
460c65bf49 Implement stat block panel fold/unfold and pin-to-second-panel
Replace the close button and heading with fold/unfold controls that
collapse the panel to a slim right-edge tab showing the creature name
vertically, and add a pin button (xl+ viewports with creature loaded)
that opens the creature in a second left-side panel for simultaneous
reference. Fold state is respected on turn change. 19 acceptance tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:18:15 +01:00
Lukas
95cb2edc23 Redesign top bar with dedicated round badge and centered combatant name
Replace the combined "Round N — Name" string with a three-zone flex layout:
left (prev button + R{n} pill badge), center (prominent combatant name with
truncation), right (action buttons + next button). Adds 13 unit tests
covering all user stories including layout robustness and empty state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 12:39:57 +01:00
Lukas
55d322a727 Fix concentration glow clipping at container edges
Add padding to the inner combatant list container so the box-shadow glow
from the concentration-damage animation renders fully on all sides,
preventing clipping by the scrollable parent's implicit overflow-x.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 11:54:22 +01:00
Lukas
0c903bc9a5 Fix ConfirmButton Enter/Space keydown bubbling to parent row handler
The button's onClick stopped mouse event propagation, but keyboard
Enter/Space fired a separate keydown event that bubbled to the
combatant row's onKeyDown, opening the stat block side panel instead
of arming/confirming the button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:04:27 +01:00
Lukas
236c3bf64a Add /issue-to-spec and /sync-issue Claude Code skills
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:00:47 +01:00
Lukas
0747d044f3 Implement the 032-inline-confirm-buttons feature that replaces single-click destructive actions with a reusable ConfirmButton component providing inline two-step confirmation (click to arm, click to execute), applied to the remove combatant and clear encounter buttons, with CSS scale pulse animation, 5-second auto-revert, click-outside/Escape/blur dismissal, full keyboard accessibility, and 13 unit tests via @testing-library/react
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 09:51:21 +01:00
Lukas
d101906776 Add /write-issue skill for interactive Gitea issue creation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 01:21:22 +01:00
Lukas
69363d4f7d Implement the 031-quality-gates-hygiene feature that strengthens automated quality gates by adding pnpm audit to the check script, v8 coverage thresholds with per-directory auto-ratchet (domain 96%, adapters 71%, persistence 87%), Biome cognitive complexity enforcement (max 15), and keyboard accessibility for the combatant row, while cleaning up all blanket biome-ignore comments, refactoring 5 overly complex functions into smaller helpers, and codifying the early-enforcement principle in the constitution and CLAUDE.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 00:52:29 +01:00
Lukas
47da942b73 Clean up project documentation and constitution for post-MVP maturity
- CLAUDE.md: replace 32-line per-feature tech changelog with consolidated
  tech stack, add project structure and data/storage sections, remove stale
  Recent Changes
- README.md: rewrite for current feature set, fix bestiary description
  (import-based, not bundled), remove placeholder license section
- Constitution v2.2.0: remove unused Agent Boundary principle (MAJOR),
  add README and CLAUDE.md sync rules to Development Workflow (MINOR)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:48:50 +01:00
Lukas
94d125d9c4 Implement the 030-bulk-import-sources feature that adds a one-click bulk import button to load all bestiary sources at once, with real-time progress feedback in the side panel and a toast notification when the panel is closed, plus completion/failure reporting with auto-dismiss on success and persistent display on partial failure, while also hardening the bestiary normalizer to handle variable stat blocks (spell summons with special AC/HP/CR) and skip malformed monster entries gracefully
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:29:34 +01:00
Lukas
c323adc343 Add spec, plan, and tasks for 030-bulk-import-sources feature
Defines the "Bulk Import All Sources" feature for the on-demand bestiary
system: one-click loading of all ~104 bestiary sources with concurrent
fetching, progress feedback, toast notifications, and completion reporting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:46:24 +01:00
Lukas
91120d7c82 Implement the 029-on-demand-bestiary feature that replaces the bundled XMM bestiary JSON with a compact search index (~350KB) and on-demand source loading, where users explicitly provide a URL or upload a JSON file to fetch full stat block data per source, which is then normalized and cached in IndexedDB (with in-memory fallback) so creature stat blocks load instantly on subsequent visits while keeping the app bundle small and never auto-fetching copyrighted content
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:46:13 +01:00
260 changed files with 43886 additions and 78784 deletions

View File

@@ -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@<host>:<port>/<owner>/<repo>.git` or `https://<host>/<owner>/<repo>.git`
Extract:
- `GITEA_HOST` — the hostname
- `OWNER` — the repo owner/org
- `REPO` — the repo name (strip `.git` suffix)
- `API_BASE``https://<GITEA_HOST>/api/v1`
## Execution
### Step 1 — Fetch the issue
```bash
curl -sf -H "Authorization: token $GITEA_TOKEN_ISSUES" "$API_BASE/repos/$OWNER/$REPO/issues/<NUMBER>"
```
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/<NUMBER>/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"
- Only if the spec adds substantive new criteria not already captured in the issue: "Run `/sync-issue <number>` to update the Gitea issue with the new acceptance criteria". Skip this if the spec merely reformulates what the issue already says into Given/When/Then format.
## 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.

View File

@@ -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 - 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.) - Skip if project is purely internal (build scripts, one-off tools, etc.)
3. **Agent context update**: **Output**: data-model.md, /contracts/*, quickstart.md
- 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
## Key rules ## Key rules

View File

@@ -0,0 +1,162 @@
---
description: Update a Gitea issue with business-level acceptance criteria extracted from the feature spec's user stories.
---
## User Input
```text
$ARGUMENTS
```
You **MUST** provide an issue number as the argument (e.g. `/sync-issue 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@<host>:<port>/<owner>/<repo>.git` or `https://<host>/<owner>/<repo>.git`
Extract:
- `GITEA_HOST` — the hostname
- `OWNER` — the repo owner/org
- `REPO` — the repo name (strip `.git` suffix)
- `API_BASE``https://<GITEA_HOST>/api/v1`
3. Locate the spec file. List the available feature specs:
```bash
ls specs/*/spec.md
```
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`. Extract user stories and acceptance scenarios using these patterns:
**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(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
For each user story, extract the acceptance scenarios and rewrite them as concise, business-level checkbox items. Group by user story title.
**Transformation rules:**
- Strip Given/When/Then syntax — write as plain outcomes
- Remove implementation details (API names, database references, component names, file paths, config values, tool names)
- Focus on what the user **can do** or **can see**
- Keep each item to one line
- Preserve the grouping by user story for readability
**Example transformation:**
Input (from spec):
```
**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.
```
Output (for issue):
```
- [ ] Clicking the import button opens a panel with a description, editable URL, and "Load All" button
```
Also include edge cases as a separate group if they describe user-facing behavior.
### Step 3 — Fetch the existing issue
```bash
curl -sf -H "Authorization: token $GITEA_TOKEN_ISSUES" "$API_BASE/repos/$OWNER/$REPO/issues/<NUMBER>"
```
Extract the current `body` from the response.
### Step 4 — Update the issue body
Merge the acceptance criteria into the existing issue body:
- If the body already has an `## Acceptance Criteria` section, **replace** its contents (everything between `## Acceptance Criteria` and the next `##` heading or end of body).
- If the body does not have an `## Acceptance Criteria` section, insert it after the `## Summary` section (or at the end if no Summary exists).
Preserve all other sections of the issue body unchanged.
The acceptance criteria section should look like:
```markdown
## Acceptance Criteria
### <User Story 1 Title>
- [ ] <criterion from acceptance scenario>
- [ ] <criterion from acceptance scenario>
### <User Story 2 Title>
- [ ] <criterion from acceptance scenario>
### Edge Cases
- [ ] <edge case behavior>
```
### Step 5 — Preview and confirm
Show the user:
- The full updated issue body
- A diff summary of what changed (sections added/replaced)
Ask for confirmation before updating.
### Step 6 — Push the update
On confirmation:
```bash
curl -sf -X PATCH \
-H "Authorization: token $GITEA_TOKEN_ISSUES" \
-H "Content-Type: application/json" \
"$API_BASE/repos/$OWNER/$REPO/issues/<NUMBER>" \
-d @- <<'PAYLOAD'
{
"body": "<updated body>"
}
PAYLOAD
```
Report success with the issue URL.
## Behavior Rules
- Never modify the issue title, labels, milestone, or assignees — only the body.
- Always preview before updating — never push without user confirmation.
- If the spec has no user stories or acceptance scenarios, abort with a clear message suggesting the user run `/speckit.specify` first.
- Acceptance criteria must be business-level. If you find yourself writing implementation details, rewrite at a higher level of abstraction.
- Do NOT sync when the issue's existing acceptance criteria already capture the same requirements as the spec. The spec's Given/When/Then format is not needed in the issue — if the only difference is formatting, skip the sync and tell the user the criteria already align.
- Use `curl` for all API calls — do not rely on `gh` CLI.
- Always use HEREDOC for the JSON payload to handle special characters in the body.
- Escape double quotes and newlines properly in the JSON body.

View File

@@ -0,0 +1,163 @@
---
description: Interactive interview to create a well-structured Gitea issue with business-level acceptance criteria.
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## 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 base URL, owner, and repo:
```bash
git config --get remote.origin.url
```
Expected format: `ssh://git@<host>:<port>/<owner>/<repo>.git` or `https://<host>/<owner>/<repo>.git`
Extract:
- `GITEA_HOST` — the hostname (e.g. `git.bahamut.nitrix.one`)
- `GITEA_PORT` — the port if present in SSH URL (for API, always use HTTPS on default port)
- `OWNER` — the repo owner/org
- `REPO` — the repo name (strip `.git` suffix)
- `API_BASE``https://<GITEA_HOST>/api/v1`
Verify the remote is reachable:
```bash
curl -sf -H "Authorization: token $GITEA_TOKEN_ISSUES" "$API_BASE/repos/$OWNER/$REPO" | head -c 200
```
If this fails, abort with an error explaining the API is unreachable.
## Interview Flow
### Step 1 — What's the feature?
If `$ARGUMENTS` is not empty, use it as the initial feature description and skip asking.
Otherwise, ask the user: **"What feature or change do you want to build?"**
Accept a free-form description (1-3 sentences is fine).
### Step 2 — Contextual business questions
Analyze the feature description and identify what's ambiguous or underspecified **for this specific feature**. Generate 1-3 targeted questions that would materially improve the issue.
Rules:
- Questions MUST be derived from the specific feature description, not from a generic checklist.
- Only ask questions whose answers change the scope, behavior, or acceptance criteria.
- If the feature is straightforward and well-described, skip this step entirely.
- Present one question at a time using the AskUserQuestion tool.
- For each question, provide 2-4 concrete options plus the ability to give a custom answer.
- After each answer, decide if you need another question or have enough clarity.
Examples of good contextual questions:
- For a "color overhaul" feature: "Are you targeting specific components or a global palette change?"
- For a "bulk import" feature: "Should the user see progress during import or just a final result?"
- For an "initiative tiebreaker" feature: "Should ties be broken by DEX score, manual choice, or random roll?"
Examples of bad questions (never ask these):
- Generic: "Who is the target user?"
- Obvious: "Should errors be handled?"
- Implementation: "What database should we use?"
### Step 3 — Acceptance criteria
Based on the description and clarifications, draft 3-8 business-level acceptance criteria. These should describe **what the user can do or see**, not how the system implements it.
Present the draft to the user and ask if they want to add, remove, or change any.
Good: "User can load all sources with a single click"
Bad: "System fires concurrent fetch requests to IndexedDB"
### Step 4 — Labels and milestone (optional)
Fetch available labels and milestones from the Gitea API:
```bash
curl -sf -H "Authorization: token $GITEA_TOKEN_ISSUES" "$API_BASE/repos/$OWNER/$REPO/labels"
curl -sf -H "Authorization: token $GITEA_TOKEN_ISSUES" "$API_BASE/repos/$OWNER/$REPO/milestones"
```
If labels/milestones exist, present them to the user and ask which (if any) to apply. If none exist or the API returns empty, skip this step.
### Step 5 — Branch linking
Check if we're on a feature branch:
```bash
git branch --show-current
```
If the current branch is not `main` (and looks like a feature branch, e.g. `NNN-feature-name`), offer to include it in the issue body as a linked branch.
### Step 6 — Preview and create
Compose the issue body using this template:
```markdown
## Summary
<2-3 sentence description synthesized from the interview>
## Acceptance Criteria
- [ ] <criterion 1>
- [ ] <criterion 2>
- [ ] <criterion 3>
...
## Branch
`<branch-name>` *(if linked in step 5)*
```
Show the full preview (title + body) to the user and ask for confirmation.
On confirmation, create the issue:
```bash
curl -sf -X POST \
-H "Authorization: token $GITEA_TOKEN_ISSUES" \
-H "Content-Type: application/json" \
"$API_BASE/repos/$OWNER/$REPO/issues" \
-d @- <<'PAYLOAD'
{
"title": "<issue title>",
"body": "<issue body>",
"labels": [<label IDs if selected>],
"milestone": <milestone ID if selected or null>
}
PAYLOAD
```
Parse the response and report:
- Issue number and URL (`https://<GITEA_HOST>/<OWNER>/<REPO>/issues/<number>`)
- Suggest next step: `/integrate-issue <number>` to integrate into a feature spec
## Behavior Rules
- Keep the title short — under 70 characters.
- Acceptance criteria must be business-level, not implementation details.
- Never create an issue without user confirmation of the preview.
- If any API call fails, show the error and suggest the user check their token permissions.
- Use `curl` for all API calls — do not rely on `gh` CLI.
- Always use HEREDOC for the JSON payload to handle special characters in the body.
- Escape double quotes and newlines properly in the JSON body.

View File

@@ -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.

View File

@@ -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 <skill_directory>/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

View File

@@ -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()

View File

@@ -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 <skill_directory>/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

View File

@@ -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()

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
.pnpm-store
dist
coverage
.git
.claude
.specify
specs
docs

49
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,49 @@
name: CI
on:
push:
branches: [main]
tags: ["*"]
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: pnpm install --frozen-lockfile
- run: pnpm check
build-image:
if: startsWith(github.ref, 'refs/tags/')
needs: check
runs-on: host
steps:
- uses: actions/checkout@v4
- name: Log in to Gitea registry
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.bahamut.nitrix.one -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
- name: Build and push
run: |
IMAGE=git.bahamut.nitrix.one/dostulata/initiative
TAG=${GITHUB_REF#refs/tags/}
docker build -t $IMAGE:$TAG -t $IMAGE:latest .
docker push $IMAGE:$TAG
docker push $IMAGE:latest
- name: Deploy
run: |
IMAGE=git.bahamut.nitrix.one/dostulata/initiative
TAG=${GITHUB_REF#refs/tags/}
docker stop initiative || true
docker rm initiative || true
docker run -d --name initiative --restart unless-stopped -p 8080:80 $IMAGE:$TAG

View File

@@ -1,14 +1,10 @@
<!-- <!--
Sync Impact Report Sync Impact Report
─────────────────── ───────────────────
Version change: 1.0.21.0.3 (PATCH — add merge-gate rule) Version change: 2.2.13.0.0 (MAJOR — specs describe features not changes, proportional workflow)
Modified sections: Modified sections:
- Development Workflow: added automated-checks merge gate - Development Workflow: specs are living feature documents; full pipeline for new features only
Templates requiring updates: Templates requiring updates: none
- .specify/templates/plan-template.md ✅ no update needed
- .specify/templates/spec-template.md ✅ no update needed
- .specify/templates/tasks-template.md ✅ no update needed
Follow-up TODOs: none
--> -->
# Encounter Console Constitution # Encounter Console Constitution
@@ -29,7 +25,7 @@ be injected at the boundary, never sourced inside the domain layer.
### II. Layered Architecture ### II. Layered Architecture
The codebase MUST be organized into four layers with strict The codebase MUST be organized into three layers with strict
dependency direction: dependency direction:
1. **Domain** — pure types, state transitions, validation rules. 1. **Domain** — pure types, state transitions, validation rules.
@@ -39,34 +35,21 @@ dependency direction:
interfaces that Adapters implement. May import Domain only. interfaces that Adapters implement. May import Domain only.
3. **Adapters** — I/O, persistence, UI rendering, external APIs. 3. **Adapters** — I/O, persistence, UI rendering, external APIs.
May import Application and Domain. May import Application and Domain.
4. **Agent** — AI-assisted features (suggestions, analysis).
May import Application and Domain as read-only consumers.
A module in an inner layer MUST NOT import from an outer layer. A module in an inner layer MUST NOT import from an outer layer.
### III. Agent Boundary ### III. Clarification-First
The agent layer MAY read domain events and current state. The agent
MAY produce suggestions, annotations, or recommendations. The agent
MUST NOT mutate domain state directly. All agent-originated changes
MUST flow through the Application layer as explicit user-confirmed
commands.
- Agent output MUST be clearly labeled as suggestions.
- No silent or automatic application of agent recommendations.
### IV. Clarification-First
Before making any non-trivial assumption during specification, Before making any non-trivial assumption during specification,
planning, or implementation, the agent MUST surface a clarification planning, or implementation, Claude Code MUST surface a clarification
question to the user. "Non-trivial" means any decision that would question to the user. "Non-trivial" means any decision that would
alter observable behavior, data model shape, or public API surface. alter observable behavior, data model shape, or public API surface.
The agent MUST also ask when multiple valid interpretations exist, Claude Code MUST also ask when multiple valid interpretations exist,
when a choice would affect architectural layering, or when scope when a choice would affect architectural layering, or when scope
would expand beyond the current spec. The agent MUST NOT silently would expand beyond the current spec. Claude Code MUST NOT silently
choose among valid alternatives. choose among valid alternatives.
### V. Escalation Gates ### IV. Escalation Gates
Any feature, requirement, or scope change not present in the current Any feature, requirement, or scope change not present in the current
spec MUST be rejected at implementation time until the spec is spec MUST be rejected at implementation time until the spec is
@@ -77,7 +60,7 @@ explicitly updated. The workflow is:
3. Update the spec via `/speckit.specify` or `/speckit.clarify`. 3. Update the spec via `/speckit.specify` or `/speckit.clarify`.
4. Only then proceed with implementation. 4. Only then proceed with implementation.
### VI. MVP Baseline Language ### V. MVP Baseline Language
Constraints in this constitution and in specs MUST use MVP baseline Constraints in this constitution and in specs MUST use MVP baseline
language ("MVP baseline does not include X") rather than permanent language ("MVP baseline does not include X") rather than permanent
@@ -86,7 +69,7 @@ add capabilities in future iterations without constitutional
amendment. The current MVP baseline is local-first and single-user; amendment. The current MVP baseline is local-first and single-user;
this is a starting scope, not a permanent restriction. this is a starting scope, not a permanent restriction.
### VII. No Gameplay Rules in Constitution ### VI. No Gameplay Rules in Constitution
This constitution MUST NOT contain concrete gameplay mechanics, This constitution MUST NOT contain concrete gameplay mechanics,
rule-system specifics, or encounter resolution logic. Such details rule-system specifics, or encounter resolution logic. Such details
@@ -96,9 +79,9 @@ architecture, and quality — not product behavior.
## Scope Constraints ## Scope Constraints
- The Encounter Console's primary focus is initiative tracking and - The Encounter Console's primary focus is initiative tracking and
encounter state management. Adjacent capabilities (e.g., richer encounter state management. Adjacent capabilities (e.g., bestiary
game-engine features) are not in the MVP baseline but may be integration, richer game-engine features) may be added via spec
added via spec updates in future iterations. updates.
- Technology choices, UI framework, and storage mechanism are - Technology choices, UI framework, and storage mechanism are
spec-level decisions, not constitutional mandates. spec-level decisions, not constitutional mandates.
- Testing strategy (unit, integration, contract) is determined per - Testing strategy (unit, integration, contract) is determined per
@@ -109,16 +92,31 @@ architecture, and quality — not product behavior.
- No change may be merged unless all automated checks (tests and - No change may be merged unless all automated checks (tests and
static analysis as defined by the project) pass. static analysis as defined by the project) pass.
- Every feature begins with a spec (`/speckit.specify`). - Specs describe **features**, not individual changes. Each spec is
- Implementation follows the plan → tasks → implement pipeline. 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. - Domain logic MUST be testable without mocks for external systems.
- Long-running or multi-step state transitions SHOULD be verifiable - Long-running or multi-step state transitions SHOULD be verifiable
through reproducible event logs or snapshot-style tests. through reproducible event logs or snapshot-style tests.
- Commits SHOULD be atomic and map to individual tasks where - Commits SHOULD be atomic and map to individual tasks where
practical. practical.
- Layer boundary compliance MUST be verified by automated import - Layer boundary compliance MUST be verified by automated import
rules or architectural tests. Agent-assisted or manual review MAY rules or architectural tests.
supplement but not replace automated checks. - All automated quality gates MUST run at the earliest feasible
enforcement point (currently pre-commit via Lefthook). No gate
may exist only as a CI step or manual process.
- When a feature adds, removes, or changes user-facing capabilities
described in README.md, the README MUST be updated in the same
change. Features that materially alter what the product does or
how it is set up SHOULD also be reflected in the README.
- When a feature changes the tech stack, project structure, or
architectural patterns documented in CLAUDE.md, the CLAUDE.md
MUST be updated in the same change.
## Governance ## Governance
@@ -142,4 +140,4 @@ MUST comply with its principles.
**Compliance review**: Every spec and plan MUST include a **Compliance review**: Every spec and plan MUST include a
Constitution Check section validating adherence to all principles. Constitution Check section validating adherence to all principles.
**Version**: 1.0.3 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-03 **Version**: 3.0.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-11

105
CLAUDE.md
View File

@@ -5,7 +5,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Commands ## Commands
```bash ```bash
pnpm check # Merge gate — must pass before every commit (knip + format + lint + typecheck + test) pnpm check # Merge gate — must pass before every commit (audit + knip + biome + typecheck + test/coverage + jscpd)
pnpm knip # Unused code detection (Knip) pnpm knip # Unused code detection (Knip)
pnpm test # Run all tests (Vitest) pnpm test # Run all tests (Vitest)
pnpm test:watch # Tests in watch mode pnpm test:watch # Tests in watch mode
@@ -27,11 +27,40 @@ apps/web (React 19 + Vite) → packages/application (use cases) → packages
``` ```
- **Domain** — Pure functions, no I/O, no framework imports. All state transitions are deterministic. Errors returned as values (`DomainError`), never thrown. Adapters may throw only for programmer errors. - **Domain** — Pure functions, no I/O, no framework imports. All state transitions are deterministic. Errors returned as values (`DomainError`), never thrown. Adapters may throw only for programmer errors.
- **Application** — Orchestrates domain calls via port interfaces (e.g., `EncounterStore`). No business logic here. - **Application** — Orchestrates domain calls via port interfaces (`EncounterStore`, `BestiarySourceCache`). No business logic here.
- **Web** — React adapter. Implements ports using hooks/state. - **Web** — React adapter. Implements ports using hooks/state. All UI components, routing, and user interaction live here.
Layer boundaries are enforced by `scripts/check-layer-boundaries.mjs`, which runs as a Vitest test. Domain and application must never import from React, Vite, or upper layers. Layer boundaries are enforced by `scripts/check-layer-boundaries.mjs`, which runs as a Vitest test. Domain and application must never import from React, Vite, or upper layers.
### Data & Storage
- **localStorage** — encounter persistence (adapter layer, JSON serialization)
- **IndexedDB** — bestiary source cache (`apps/web/src/adapters/bestiary-cache.ts`, via `idb` wrapper)
- **`data/bestiary/index.json`** — pre-built search index for creature lookup, generated by `scripts/generate-bestiary-index.mjs`
### Project Structure
```
apps/web/ React app — components, hooks, adapters
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/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
- TypeScript 5.8 (strict mode, `verbatimModuleSyntax`)
- React 19, Vite 6, Tailwind CSS v4
- Lucide React (icons)
- `idb` (IndexedDB wrapper for bestiary cache)
- Biome 2.0 (formatting + linting), Knip (unused code), jscpd (copy-paste detection)
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
## Conventions ## Conventions
- **Biome 2.0** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically. - **Biome 2.0** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
@@ -39,7 +68,39 @@ Layer boundaries are enforced by `scripts/check-layer-boundaries.mjs`, which run
- **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical. - **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. - **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. - **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/<feature>/` 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 <number>` — fetch an issue, route it to the right spec, and update the spec with the new/changed requirements. Then implement directly.
- `/sync-issue <number>` — 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) ## Constitution (key principles)
@@ -49,38 +110,4 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
2. **Layered Architecture** — Domain → Application → Adapters. Never skip layers or reverse dependencies. 2. **Layered Architecture** — Domain → Application → Adapters. Never skip layers or reverse dependencies.
3. **Clarification-First** — Ask before making non-trivial assumptions. 3. **Clarification-First** — Ask before making non-trivial assumptions.
4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans. 4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans.
5. **Every feature begins with a spec** — Spec → Plan → Tasks → Implementation. 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.
## Active Technologies
- TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite (003-remove-combatant)
- In-memory React state (local-first, single-user MVP) (003-remove-combatant)
- TypeScript 5.x (project), Go binary via npm (Lefthook) + `lefthook` (npm devDependency) (006-pre-commit-gate)
- TypeScript 5.x (strict mode, verbatimModuleSyntax) + Knip v5 (new), Biome 2.0, Vitest, Vite 6, React 19 (007-add-knip)
- TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Biome 2.0, existing domain/application packages (008-persist-encounter)
- Browser localStorage (adapter layer only) (008-persist-encounter)
- TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Biome 2.0, Vites (009-combatant-hp)
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Tailwind CSS v4, shadcn/ui, Lucide React (icons) (010-ui-baseline)
- N/A (no storage changes — localStorage persistence unchanged) (010-ui-baseline)
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Tailwind CSS v4, shadcn/ui-style components, Lucide React (icons) (011-quick-hp-input)
- N/A (no storage changes -- existing localStorage persistence handles HP via `adjustHpUseCase`) (011-quick-hp-input)
- N/A (no storage changes -- existing localStorage persistence unchanged) (012-turn-navigation)
- N/A (no storage changes -- purely derived state, existing localStorage persistence unchanged) (013-hp-status-indicators)
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + jscpd (new dev dependency), Lefthook (existing), Biome 2.0 (existing), Knip (existing) (015-add-jscpd-gate)
- N/A (no storage changes) (015-add-jscpd-gate)
- Browser localStorage (existing adapter, transparent JSON serialization) (016-combatant-ac)
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Tailwind CSS v4, Lucide React (icons) (017-combat-conditions)
- N/A (no storage changes — existing localStorage persistence unchanged) (019-combatant-row-declutter)
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Tailwind CSS v4, Lucide React (icons) (020-fix-zero-hp-opacity)
- Browser localStorage (existing adapter, extended for creatureId) (021-bestiary-statblock)
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Tailwind CSS v4 + React 19, Tailwind CSS v4, Vite 6 (022-fixed-layout-bars)
- N/A (no storage changes -- purely presentational) (022-fixed-layout-bars)
- Browser localStorage (existing adapter, updated to handle empty encounters) (023-clear-encounter)
- N/A (no storage changes — purely presentational fix) (024-fix-hp-popover-overflow)
- N/A (no storage changes — purely derived from existing bestiary data) (025-display-initiative)
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Tailwind CSS v4, Lucide React (icons), Vite 6 (026-roll-initiative)
- N/A (no storage changes — existing localStorage persistence handles initiative via `setInitiativeUseCase`) (026-roll-initiative)
- N/A (no storage changes — purely presentational) (027-ui-polish)
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Tailwind CSS v4, Vite 6 + Tailwind CSS v4 (CSS-native `@theme` theming), Lucide React (icons) (028-semantic-hover-tokens)
## Recent Changes
- 003-remove-combatant: Added TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM node:22-slim AS build
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/domain/package.json packages/domain/
COPY packages/application/package.json packages/application/
COPY apps/web/package.json apps/web/
RUN pnpm install --frozen-lockfile --ignore-scripts
COPY . .
RUN pnpm --filter web build
FROM nginx:alpine
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@@ -1,10 +1,17 @@
# Initiative Tracker # Encounter Console
A turn-based initiative tracker for tabletop RPG encounters. Click "Next Turn" to cycle through combatants and advance rounds. A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e / 2024). Runs entirely in the browser — no server, no account, no data leaves your machine.
## What it does
- **Initiative tracking** — add combatants (batch-add from bestiary, custom creatures with optional stats), roll initiative (manual or d20), cycle turns and rounds
- **Encounter state** — HP, AC, conditions, concentration tracking with visual status indicators
- **Bestiary integration** — import bestiary JSON sources, search creatures, and view full stat blocks
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB
## Prerequisites ## Prerequisites
- Node.js 22 - Node.js 22+
- pnpm 10.6+ - pnpm 10.6+
## Getting Started ## Getting Started
@@ -14,9 +21,7 @@ pnpm install
pnpm --filter web dev pnpm --filter web dev
``` ```
Open the URL printed in your terminal (typically `http://localhost:5173`). Open `http://localhost:5173`.
The app starts with a 3-combatant demo encounter (Aria, Brak, Cael). Click **Next Turn** to advance through the initiative order. When the last combatant finishes their turn, the round number increments and the cycle restarts.
## Scripts ## Scripts
@@ -24,5 +29,27 @@ The app starts with a 3-combatant demo encounter (Aria, Brak, Cael). Click **Nex
|---------|-------------| |---------|-------------|
| `pnpm --filter web dev` | Start the dev server | | `pnpm --filter web dev` | Start the dev server |
| `pnpm --filter web build` | Production build | | `pnpm --filter web build` | Production build |
| `pnpm test` | Run all tests | | `pnpm test` | Run all tests (Vitest) |
| `pnpm check` | Full merge gate (format, lint, typecheck, test) | | `pnpm check` | Full merge gate (knip, biome, typecheck, test, jscpd) |
## Project Structure
```
apps/web/ React 19 + Vite — UI components, hooks, adapters
packages/domain/ Pure functions — state transitions, types, validation
packages/app/ Use cases — orchestrates domain via port interfaces
data/bestiary/ Bestiary index for creature search
scripts/ Build tooling (layer boundary checks, index generation)
specs/ Feature specifications (spec → plan → tasks)
```
## Architecture
Strict layered architecture with enforced dependency direction:
```
apps/web (adapters) → packages/application (use cases) → packages/domain (pure logic)
```
Domain is pure — no I/O, no randomness, no framework imports. Layer boundaries are enforced by automated import checks that run as part of the test suite. See [CLAUDE.md](./CLAUDE.md) for full conventions.

View File

@@ -13,6 +13,7 @@
"@initiative/domain": "workspace:*", "@initiative/domain": "workspace:*",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"idb": "^8.0.3",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -20,9 +21,12 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
"jsdom": "^28.1.0",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"vite": "^6.2.0" "vite": "^6.2.0"
} }

View File

@@ -6,9 +6,12 @@ import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { ActionBar } from "./components/action-bar"; import { ActionBar } from "./components/action-bar";
import { CombatantRow } from "./components/combatant-row"; import { CombatantRow } from "./components/combatant-row";
import { SourceManager } from "./components/source-manager";
import { StatBlockPanel } from "./components/stat-block-panel"; import { StatBlockPanel } from "./components/stat-block-panel";
import { Toast } from "./components/toast";
import { TurnNavigation } from "./components/turn-navigation"; import { TurnNavigation } from "./components/turn-navigation";
import { useBestiary } from "./hooks/use-bestiary"; import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
import { useBulkImport } from "./hooks/use-bulk-import";
import { useEncounter } from "./hooks/use-encounter"; import { useEncounter } from "./hooks/use-encounter";
function rollDice(): number { function rollDice(): number {
@@ -34,44 +37,65 @@ export function App() {
makeStore, makeStore,
} = useEncounter(); } = useEncounter();
const { search, getCreature, isLoaded } = useBestiary(); const {
search,
getCreature,
isLoaded,
isSourceCached,
fetchAndCacheSource,
uploadAndCacheSource,
refreshCache,
} = useBestiary();
const [selectedCreature, setSelectedCreature] = useState<Creature | null>( const bulkImport = useBulkImport();
const [selectedCreatureId, setSelectedCreatureId] =
useState<CreatureId | null>(null);
const [bulkImportMode, setBulkImportMode] = useState(false);
const [sourceManagerOpen, setSourceManagerOpen] = useState(false);
const [isRightPanelFolded, setIsRightPanelFolded] = useState(false);
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
null, null,
); );
const [suggestions, setSuggestions] = useState<Creature[]>([]); const [isWideDesktop, setIsWideDesktop] = useState(
() => window.matchMedia("(min-width: 1280px)").matches,
);
useEffect(() => {
const mq = window.matchMedia("(min-width: 1280px)");
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const selectedCreature: Creature | null = selectedCreatureId
? (getCreature(selectedCreatureId) ?? null)
: null;
const pinnedCreature: Creature | null = pinnedCreatureId
? (getCreature(pinnedCreatureId) ?? null)
: null;
const handleAddFromBestiary = useCallback( const handleAddFromBestiary = useCallback(
(creature: Creature) => { (result: SearchResult) => {
addFromBestiary(creature); addFromBestiary(result);
setSelectedCreature(creature); // Derive the creature ID so stat block panel can try to show it
const slug = result.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
setSelectedCreatureId(
`${result.source.toLowerCase()}:${slug}` as CreatureId,
);
}, },
[addFromBestiary], [addFromBestiary],
); );
const handleShowStatBlock = useCallback((creature: Creature) => { const handleCombatantStatBlock = useCallback((creatureId: string) => {
setSelectedCreature(creature); setSelectedCreatureId(creatureId as CreatureId);
setIsRightPanelFolded(false);
}, []); }, []);
const handleCombatantStatBlock = useCallback(
(creatureId: string) => {
const creature = getCreature(creatureId as CreatureId);
if (creature) setSelectedCreature(creature);
},
[getCreature],
);
const handleSearchChange = useCallback(
(query: string) => {
if (!isLoaded || query.length < 2) {
setSuggestions([]);
return;
}
setSuggestions(search(query));
},
[isLoaded, search],
);
const handleRollInitiative = useCallback( const handleRollInitiative = useCallback(
(id: CombatantId) => { (id: CombatantId) => {
rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature); rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature);
@@ -83,6 +107,59 @@ export function App() {
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature); rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
}, [makeStore, getCreature]); }, [makeStore, getCreature]);
const handleViewStatBlock = useCallback((result: SearchResult) => {
const slug = result.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
setSelectedCreatureId(cId);
setIsRightPanelFolded(false);
}, []);
const handleBulkImport = useCallback(() => {
setBulkImportMode(true);
setSelectedCreatureId(null);
}, []);
const handleStartBulkImport = useCallback(
(baseUrl: string) => {
bulkImport.startImport(
baseUrl,
fetchAndCacheSource,
isSourceCached,
refreshCache,
);
},
[bulkImport.startImport, fetchAndCacheSource, isSourceCached, refreshCache],
);
const handleBulkImportDone = useCallback(() => {
setBulkImportMode(false);
bulkImport.reset();
}, [bulkImport.reset]);
const handleDismissBrowsePanel = useCallback(() => {
setSelectedCreatureId(null);
setBulkImportMode(false);
}, []);
const handleToggleFold = useCallback(() => {
setIsRightPanelFolded((f) => !f);
}, []);
const handlePin = useCallback(() => {
if (selectedCreatureId) {
setPinnedCreatureId((prev) =>
prev === selectedCreatureId ? null : selectedCreatureId,
);
}
}, [selectedCreatureId]);
const handleUnpin = useCallback(() => {
setPinnedCreatureId(null);
}, []);
// Auto-scroll to the active combatant when the turn changes // Auto-scroll to the active combatant when the turn changes
const activeRowRef = useRef<HTMLDivElement>(null); const activeRowRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@@ -102,9 +179,8 @@ export function App() {
if (!window.matchMedia("(min-width: 1024px)").matches) return; if (!window.matchMedia("(min-width: 1024px)").matches) return;
const active = encounter.combatants[encounter.activeIndex]; const active = encounter.combatants[encounter.activeIndex];
if (!active?.creatureId || !isLoaded) return; if (!active?.creatureId || !isLoaded) return;
const creature = getCreature(active.creatureId as CreatureId); setSelectedCreatureId(active.creatureId as CreatureId);
if (creature) setSelectedCreature(creature); }, [encounter.activeIndex, encounter.combatants, isLoaded]);
}, [encounter.activeIndex, encounter.combatants, getCreature, isLoaded]);
return ( return (
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
@@ -117,12 +193,19 @@ export function App() {
onRetreatTurn={retreatTurn} onRetreatTurn={retreatTurn}
onClearEncounter={clearEncounter} onClearEncounter={clearEncounter}
onRollAllInitiative={handleRollAllInitiative} onRollAllInitiative={handleRollAllInitiative}
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
/> />
</div> </div>
{sourceManagerOpen && (
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
<SourceManager onCacheCleared={refreshCache} />
</div>
)}
{/* Scrollable area — combatant list */} {/* Scrollable area — combatant list */}
<div className="flex-1 overflow-y-auto min-h-0"> <div className="flex-1 overflow-y-auto min-h-0">
<div className="flex flex-col pb-2"> <div className="flex flex-col px-2 py-2">
{encounter.combatants.length === 0 ? ( {encounter.combatants.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground"> <p className="py-12 text-center text-sm text-muted-foreground">
No combatants yet add one to get started No combatants yet add one to get started
@@ -163,18 +246,81 @@ export function App() {
onAddFromBestiary={handleAddFromBestiary} onAddFromBestiary={handleAddFromBestiary}
bestiarySearch={search} bestiarySearch={search}
bestiaryLoaded={isLoaded} bestiaryLoaded={isLoaded}
suggestions={suggestions} onViewStatBlock={handleViewStatBlock}
onSearchChange={handleSearchChange} onBulkImport={handleBulkImport}
onShowStatBlock={handleShowStatBlock} bulkImportDisabled={bulkImport.state.status === "loading"}
/> />
</div> </div>
</div> </div>
{/* Stat Block Panel */} {/* Pinned Stat Block Panel (left) */}
{pinnedCreatureId && isWideDesktop && (
<StatBlockPanel <StatBlockPanel
creature={selectedCreature} creatureId={pinnedCreatureId}
onClose={() => setSelectedCreature(null)} creature={pinnedCreature}
isSourceCached={isSourceCached}
fetchAndCacheSource={fetchAndCacheSource}
uploadAndCacheSource={uploadAndCacheSource}
refreshCache={refreshCache}
panelRole="pinned"
isFolded={false}
onToggleFold={() => {}}
onPin={() => {}}
onUnpin={handleUnpin}
showPinButton={false}
side="left"
onDismiss={() => {}}
/> />
)}
{/* Browse Stat Block Panel (right) */}
<StatBlockPanel
creatureId={selectedCreatureId}
creature={selectedCreature}
isSourceCached={isSourceCached}
fetchAndCacheSource={fetchAndCacheSource}
uploadAndCacheSource={uploadAndCacheSource}
refreshCache={refreshCache}
panelRole="browse"
isFolded={isRightPanelFolded}
onToggleFold={handleToggleFold}
onPin={handlePin}
onUnpin={() => {}}
showPinButton={isWideDesktop && !!selectedCreature}
side="right"
onDismiss={handleDismissBrowsePanel}
bulkImportMode={bulkImportMode}
bulkImportState={bulkImport.state}
onStartBulkImport={handleStartBulkImport}
onBulkImportDone={handleBulkImportDone}
/>
{/* Toast for bulk import progress when panel is closed */}
{bulkImport.state.status === "loading" && !bulkImportMode && (
<Toast
message={`Loading sources... ${bulkImport.state.completed + bulkImport.state.failed}/${bulkImport.state.total}`}
progress={
bulkImport.state.total > 0
? (bulkImport.state.completed + bulkImport.state.failed) /
bulkImport.state.total
: 0
}
onDismiss={() => {}}
/>
)}
{bulkImport.state.status === "complete" && !bulkImportMode && (
<Toast
message="All sources loaded"
onDismiss={bulkImport.reset}
autoDismissMs={3000}
/>
)}
{bulkImport.state.status === "partial-failure" && !bulkImportMode && (
<Toast
message={`Loaded ${bulkImport.state.completed}/${bulkImport.state.total} sources (${bulkImport.state.failed} failed)`}
onDismiss={bulkImport.reset}
/>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
getAllSourceCodes,
getDefaultFetchUrl,
} from "../adapters/bestiary-index-adapter.js";
describe("getAllSourceCodes", () => {
it("returns all keys from the index sources object", () => {
const codes = getAllSourceCodes();
expect(codes.length).toBeGreaterThan(0);
expect(Array.isArray(codes)).toBe(true);
for (const code of codes) {
expect(typeof code).toBe("string");
}
});
});
describe("getDefaultFetchUrl", () => {
it("returns the default URL when no baseUrl is provided", () => {
const url = getDefaultFetchUrl("XMM");
expect(url).toBe(
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/bestiary-xmm.json",
);
});
it("constructs URL from baseUrl with trailing slash", () => {
const url = getDefaultFetchUrl("PHB", "https://example.com/data/");
expect(url).toBe("https://example.com/data/bestiary-phb.json");
});
it("normalizes baseUrl without trailing slash", () => {
const url = getDefaultFetchUrl("PHB", "https://example.com/data");
expect(url).toBe("https://example.com/data/bestiary-phb.json");
});
it("lowercases the source code in the filename", () => {
const url = getDefaultFetchUrl("MM", "https://example.com/");
expect(url).toBe("https://example.com/bestiary-mm.json");
});
});

View File

@@ -0,0 +1,218 @@
// @vitest-environment jsdom
import {
act,
cleanup,
fireEvent,
render,
screen,
} from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ConfirmButton } from "../components/ui/confirm-button";
function XIcon() {
return <span data-testid="x-icon">X</span>;
}
function renderButton(
props: Partial<Parameters<typeof ConfirmButton>[0]> = {},
) {
const onConfirm = props.onConfirm ?? vi.fn();
render(
<ConfirmButton
icon={<XIcon />}
label="Remove combatant"
onConfirm={onConfirm}
{...props}
/>,
);
return { onConfirm };
}
describe("ConfirmButton", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
cleanup();
});
it("renders the default icon in idle state", () => {
renderButton();
expect(screen.getByTestId("x-icon")).toBeTruthy();
expect(screen.getByRole("button")).toHaveAttribute(
"aria-label",
"Remove combatant",
);
});
it("transitions to confirm state with Check icon on first click", () => {
renderButton();
fireEvent.click(screen.getByRole("button"));
expect(screen.queryByTestId("x-icon")).toBeNull();
expect(screen.getByRole("button")).toHaveAttribute(
"aria-label",
"Confirm remove combatant",
);
expect(screen.getByRole("button").className).toContain("bg-destructive");
});
it("calls onConfirm on second click in confirm state", () => {
const { onConfirm } = renderButton();
const button = screen.getByRole("button");
fireEvent.click(button);
fireEvent.click(button);
expect(onConfirm).toHaveBeenCalledOnce();
});
it("auto-reverts after 5 seconds", () => {
renderButton();
fireEvent.click(screen.getByRole("button"));
expect(screen.queryByTestId("x-icon")).toBeNull();
act(() => {
vi.advanceTimersByTime(5000);
});
expect(screen.getByTestId("x-icon")).toBeTruthy();
});
it("reverts on Escape key", () => {
renderButton();
fireEvent.click(screen.getByRole("button"));
expect(screen.queryByTestId("x-icon")).toBeNull();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.getByTestId("x-icon")).toBeTruthy();
});
it("reverts on click outside", () => {
renderButton();
fireEvent.click(screen.getByRole("button"));
expect(screen.queryByTestId("x-icon")).toBeNull();
fireEvent.mouseDown(document.body);
expect(screen.getByTestId("x-icon")).toBeTruthy();
});
it("does not enter confirm state when disabled", () => {
renderButton({ disabled: true });
fireEvent.click(screen.getByRole("button"));
expect(screen.getByTestId("x-icon")).toBeTruthy();
});
it("cleans up timer on unmount", () => {
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
renderButton();
fireEvent.click(screen.getByRole("button"));
cleanup();
expect(clearTimeoutSpy).toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
});
it("manages independent instances separately", () => {
const onConfirm1 = vi.fn();
const onConfirm2 = vi.fn();
render(
<>
<ConfirmButton
icon={<span data-testid="icon-1">1</span>}
label="Remove first"
onConfirm={onConfirm1}
/>
<ConfirmButton
icon={<span data-testid="icon-2">2</span>}
label="Remove second"
onConfirm={onConfirm2}
/>
</>,
);
const buttons = screen.getAllByRole("button");
fireEvent.click(buttons[0]);
// First is confirming, second is still idle
expect(screen.queryByTestId("icon-1")).toBeNull();
expect(screen.getByTestId("icon-2")).toBeTruthy();
});
// T008: Keyboard-specific tests
it("Enter key triggers confirm state", () => {
renderButton();
const button = screen.getByRole("button");
// Native button handles Enter via click event
fireEvent.click(button);
expect(screen.queryByTestId("x-icon")).toBeNull();
expect(button).toHaveAttribute("aria-label", "Confirm remove combatant");
});
it("Enter in confirm state calls onConfirm", () => {
const { onConfirm } = renderButton();
const button = screen.getByRole("button");
fireEvent.click(button); // enter confirm state
fireEvent.click(button); // confirm
expect(onConfirm).toHaveBeenCalledOnce();
});
it("Escape in confirm state reverts", () => {
renderButton();
fireEvent.click(screen.getByRole("button"));
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.getByTestId("x-icon")).toBeTruthy();
expect(screen.getByRole("button")).toHaveAttribute(
"aria-label",
"Remove combatant",
);
});
it("blur event reverts confirm state", () => {
renderButton();
const button = screen.getByRole("button");
fireEvent.click(button);
expect(screen.queryByTestId("x-icon")).toBeNull();
fireEvent.blur(button);
expect(screen.getByTestId("x-icon")).toBeTruthy();
});
it("Enter/Space keydown stops propagation to prevent parent handlers", () => {
const parentHandler = vi.fn();
render(
// biome-ignore lint/a11y/noStaticElementInteractions: test wrapper
<div onKeyDown={parentHandler}>
<ConfirmButton
icon={<XIcon />}
label="Remove combatant"
onConfirm={vi.fn()}
/>
</div>,
);
const button = screen.getByRole("button");
fireEvent.keyDown(button, { key: "Enter" });
fireEvent.keyDown(button, { key: " " });
expect(parentHandler).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,263 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import type { Creature, CreatureId } from "@initiative/domain";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StatBlockPanel } from "../components/stat-block-panel";
const CREATURE_ID = "srd:goblin" as CreatureId;
const CREATURE: Creature = {
id: CREATURE_ID,
name: "Goblin",
source: "SRD",
sourceDisplayName: "SRD",
size: "Small",
type: "humanoid",
alignment: "neutral evil",
ac: 15,
hp: { average: 7, formula: "2d6" },
speed: "30 ft.",
abilities: { str: 8, dex: 14, con: 10, int: 10, wis: 8, cha: 8 },
cr: "1/4",
initiativeProficiency: 0,
proficiencyBonus: 2,
passive: 9,
};
function mockMatchMedia(matches: boolean) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
interface PanelProps {
creatureId?: CreatureId | null;
creature?: Creature | null;
panelRole?: "browse" | "pinned";
isFolded?: boolean;
onToggleFold?: () => void;
onPin?: () => void;
onUnpin?: () => void;
showPinButton?: boolean;
side?: "left" | "right";
onDismiss?: () => void;
bulkImportMode?: boolean;
}
function renderPanel(overrides: PanelProps = {}) {
const props = {
creatureId: CREATURE_ID,
creature: CREATURE,
isSourceCached: vi.fn().mockResolvedValue(true),
fetchAndCacheSource: vi.fn(),
uploadAndCacheSource: vi.fn(),
refreshCache: vi.fn(),
panelRole: "browse" as const,
isFolded: false,
onToggleFold: vi.fn(),
onPin: vi.fn(),
onUnpin: vi.fn(),
showPinButton: false,
side: "right" as const,
onDismiss: vi.fn(),
...overrides,
};
render(<StatBlockPanel {...props} />);
return props;
}
describe("Stat Block Panel Fold/Unfold and Pin", () => {
beforeEach(() => {
mockMatchMedia(true); // desktop by default
});
afterEach(cleanup);
describe("US1: Fold and Unfold", () => {
it("shows fold button instead of close button on desktop", () => {
renderPanel();
expect(
screen.getByRole("button", { name: "Fold stat block panel" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /close/i }),
).not.toBeInTheDocument();
});
it("does not show 'Stat Block' heading", () => {
renderPanel();
expect(screen.queryByText("Stat Block")).not.toBeInTheDocument();
});
it("renders folded tab with creature name when isFolded is true", () => {
renderPanel({ isFolded: true });
expect(screen.getByText("Goblin")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Unfold stat block panel" }),
).toBeInTheDocument();
});
it("calls onToggleFold when fold button is clicked", () => {
const props = renderPanel();
fireEvent.click(
screen.getByRole("button", { name: "Fold stat block panel" }),
);
expect(props.onToggleFold).toHaveBeenCalledTimes(1);
});
it("calls onToggleFold when folded tab is clicked", () => {
const props = renderPanel({ isFolded: true });
fireEvent.click(
screen.getByRole("button", { name: "Unfold stat block panel" }),
);
expect(props.onToggleFold).toHaveBeenCalledTimes(1);
});
it("applies translate-x class when folded (right side)", () => {
renderPanel({ isFolded: true, side: "right" });
const panel = screen
.getByRole("button", { name: "Unfold stat block panel" })
.closest("div");
expect(panel?.className).toContain("translate-x-[calc(100%-40px)]");
});
it("applies translate-x-0 when expanded", () => {
renderPanel({ isFolded: false });
const foldBtn = screen.getByRole("button", {
name: "Fold stat block panel",
});
const panel = foldBtn.closest("div.fixed") as HTMLElement;
expect(panel?.className).toContain("translate-x-0");
});
});
describe("US1: Mobile behavior", () => {
beforeEach(() => {
mockMatchMedia(false); // mobile
});
it("shows fold button instead of X close button on mobile drawer", () => {
renderPanel();
expect(
screen.getByRole("button", { name: "Fold stat block panel" }),
).toBeInTheDocument();
// No X close icon button — only backdrop dismiss and fold toggle
const buttons = screen.getAllByRole("button");
const buttonLabels = buttons.map((b) => b.getAttribute("aria-label"));
expect(buttonLabels).not.toContain("Close");
});
it("calls onDismiss when backdrop is clicked on mobile", () => {
const props = renderPanel();
fireEvent.click(screen.getByRole("button", { name: "Close stat block" }));
expect(props.onDismiss).toHaveBeenCalledTimes(1);
});
it("does not render pinned panel on mobile", () => {
const { container } = render(
<StatBlockPanel
creatureId={CREATURE_ID}
creature={CREATURE}
isSourceCached={vi.fn().mockResolvedValue(true)}
fetchAndCacheSource={vi.fn()}
uploadAndCacheSource={vi.fn()}
refreshCache={vi.fn()}
panelRole="pinned"
isFolded={false}
onToggleFold={vi.fn()}
onPin={vi.fn()}
onUnpin={vi.fn()}
showPinButton={false}
side="left"
onDismiss={vi.fn()}
/>,
);
expect(container.innerHTML).toBe("");
});
});
describe("US2: Pin and Unpin", () => {
it("shows pin button when showPinButton is true on desktop", () => {
renderPanel({ showPinButton: true });
expect(
screen.getByRole("button", { name: "Pin creature" }),
).toBeInTheDocument();
});
it("hides pin button when showPinButton is false", () => {
renderPanel({ showPinButton: false });
expect(
screen.queryByRole("button", { name: "Pin creature" }),
).not.toBeInTheDocument();
});
it("calls onPin when pin button is clicked", () => {
const props = renderPanel({ showPinButton: true });
fireEvent.click(screen.getByRole("button", { name: "Pin creature" }));
expect(props.onPin).toHaveBeenCalledTimes(1);
});
it("shows unpin button for pinned role", () => {
renderPanel({ panelRole: "pinned", side: "left" });
expect(
screen.getByRole("button", { name: "Unpin creature" }),
).toBeInTheDocument();
});
it("calls onUnpin when unpin button is clicked", () => {
const props = renderPanel({ panelRole: "pinned", side: "left" });
fireEvent.click(screen.getByRole("button", { name: "Unpin creature" }));
expect(props.onUnpin).toHaveBeenCalledTimes(1);
});
it("positions pinned panel on the left side", () => {
renderPanel({ panelRole: "pinned", side: "left" });
const unpinBtn = screen.getByRole("button", {
name: "Unpin creature",
});
const panel = unpinBtn.closest("div.fixed") as HTMLElement;
expect(panel?.className).toContain("left-0");
expect(panel?.className).toContain("border-r");
});
it("positions browse panel on the right side", () => {
renderPanel({ panelRole: "browse", side: "right" });
const foldBtn = screen.getByRole("button", {
name: "Fold stat block panel",
});
const panel = foldBtn.closest("div.fixed") as HTMLElement;
expect(panel?.className).toContain("right-0");
expect(panel?.className).toContain("border-l");
});
});
describe("US3: Fold independence with pinned panel", () => {
it("pinned panel has no fold button", () => {
renderPanel({ panelRole: "pinned", side: "left" });
expect(
screen.queryByRole("button", { name: /fold/i }),
).not.toBeInTheDocument();
});
it("pinned panel is always expanded (no translate offset)", () => {
renderPanel({ panelRole: "pinned", side: "left", isFolded: false });
const unpinBtn = screen.getByRole("button", {
name: "Unpin creature",
});
const panel = unpinBtn.closest("div.fixed") as HTMLElement;
expect(panel?.className).toContain("translate-x-0");
});
});
});

View File

@@ -0,0 +1,235 @@
import { describe, expect, it, vi } from "vitest";
import * as indexAdapter from "../adapters/bestiary-index-adapter.js";
// We test the bulk import logic by extracting and exercising the async flow.
// Since useBulkImport is a thin React wrapper around async logic,
// we test the core behavior via a direct simulation.
vi.mock("../adapters/bestiary-index-adapter.js", async () => {
const actual = await vi.importActual<
typeof import("../adapters/bestiary-index-adapter.js")
>("../adapters/bestiary-index-adapter.js");
return {
...actual,
getAllSourceCodes: vi.fn(),
};
});
const mockGetAllSourceCodes = vi.mocked(indexAdapter.getAllSourceCodes);
interface BulkImportState {
status: "idle" | "loading" | "complete" | "partial-failure";
total: number;
completed: number;
failed: number;
}
/** Simulate the core bulk import logic extracted from the hook */
async function runBulkImport(
baseUrl: string,
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
isSourceCached: (sourceCode: string) => Promise<boolean>,
refreshCache: () => Promise<void>,
): Promise<BulkImportState> {
const allCodes = indexAdapter.getAllSourceCodes();
const total = allCodes.length;
const cacheChecks = await Promise.all(
allCodes.map(async (code) => ({
code,
cached: await isSourceCached(code),
})),
);
const alreadyCached = cacheChecks.filter((c) => c.cached).length;
const uncached = cacheChecks.filter((c) => !c.cached);
if (uncached.length === 0) {
return { status: "complete", total, completed: total, failed: 0 };
}
let completed = alreadyCached;
let failed = 0;
await Promise.allSettled(
uncached.map(async ({ code }) => {
const url = indexAdapter.getDefaultFetchUrl(code, baseUrl);
try {
await fetchAndCacheSource(code, url);
completed++;
} catch {
failed++;
}
}),
);
await refreshCache();
return {
status: failed > 0 ? "partial-failure" : "complete",
total,
completed,
failed,
};
}
describe("bulk import logic", () => {
it("skips already-cached sources and counts them into completed", async () => {
mockGetAllSourceCodes.mockReturnValue(["SRC1", "SRC2", "SRC3"]);
const fetchAndCache = vi.fn().mockResolvedValue(undefined);
const isSourceCached = vi
.fn()
.mockImplementation((code: string) =>
Promise.resolve(code === "SRC1" || code === "SRC3"),
);
const refreshCache = vi.fn().mockResolvedValue(undefined);
const result = await runBulkImport(
"https://example.com/",
fetchAndCache,
isSourceCached,
refreshCache,
);
expect(fetchAndCache).toHaveBeenCalledTimes(1);
expect(fetchAndCache).toHaveBeenCalledWith(
"SRC2",
"https://example.com/bestiary-src2.json",
);
expect(result.completed).toBe(3);
expect(result.status).toBe("complete");
});
it("increments completed on successful fetch", async () => {
mockGetAllSourceCodes.mockReturnValue(["SRC1"]);
const fetchAndCache = vi.fn().mockResolvedValue(undefined);
const isSourceCached = vi.fn().mockResolvedValue(false);
const refreshCache = vi.fn().mockResolvedValue(undefined);
const result = await runBulkImport(
"https://example.com/",
fetchAndCache,
isSourceCached,
refreshCache,
);
expect(result.completed).toBe(1);
expect(result.failed).toBe(0);
expect(result.status).toBe("complete");
});
it("increments failed on rejected fetch", async () => {
mockGetAllSourceCodes.mockReturnValue(["SRC1"]);
const fetchAndCache = vi.fn().mockRejectedValue(new Error("fail"));
const isSourceCached = vi.fn().mockResolvedValue(false);
const refreshCache = vi.fn().mockResolvedValue(undefined);
const result = await runBulkImport(
"https://example.com/",
fetchAndCache,
isSourceCached,
refreshCache,
);
expect(result.failed).toBe(1);
expect(result.completed).toBe(0);
expect(result.status).toBe("partial-failure");
});
it("transitions to complete when all succeed", async () => {
mockGetAllSourceCodes.mockReturnValue(["A", "B"]);
const fetchAndCache = vi.fn().mockResolvedValue(undefined);
const isSourceCached = vi.fn().mockResolvedValue(false);
const refreshCache = vi.fn().mockResolvedValue(undefined);
const result = await runBulkImport(
"https://example.com/",
fetchAndCache,
isSourceCached,
refreshCache,
);
expect(result.status).toBe("complete");
expect(result.completed).toBe(2);
expect(result.failed).toBe(0);
});
it("transitions to partial-failure when any fetch fails", async () => {
mockGetAllSourceCodes.mockReturnValue(["A", "B"]);
const fetchAndCache = vi
.fn()
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error("fail"));
const isSourceCached = vi.fn().mockResolvedValue(false);
const refreshCache = vi.fn().mockResolvedValue(undefined);
const result = await runBulkImport(
"https://example.com/",
fetchAndCache,
isSourceCached,
refreshCache,
);
expect(result.status).toBe("partial-failure");
expect(result.failed).toBe(1);
});
it("immediately transitions to complete when all sources are cached", async () => {
mockGetAllSourceCodes.mockReturnValue(["A", "B", "C"]);
const fetchAndCache = vi.fn();
const isSourceCached = vi.fn().mockResolvedValue(true);
const refreshCache = vi.fn().mockResolvedValue(undefined);
const result = await runBulkImport(
"https://example.com/",
fetchAndCache,
isSourceCached,
refreshCache,
);
expect(result.status).toBe("complete");
expect(result.total).toBe(3);
expect(result.completed).toBe(3);
expect(fetchAndCache).not.toHaveBeenCalled();
});
it("calls refreshCache exactly once when all settle", async () => {
mockGetAllSourceCodes.mockReturnValue(["A", "B"]);
const fetchAndCache = vi.fn().mockResolvedValue(undefined);
const isSourceCached = vi.fn().mockResolvedValue(false);
const refreshCache = vi.fn().mockResolvedValue(undefined);
await runBulkImport(
"https://example.com/",
fetchAndCache,
isSourceCached,
refreshCache,
);
expect(refreshCache).toHaveBeenCalledTimes(1);
});
it("does not call refreshCache when all sources are already cached", async () => {
mockGetAllSourceCodes.mockReturnValue(["A"]);
const fetchAndCache = vi.fn();
const isSourceCached = vi.fn().mockResolvedValue(true);
const refreshCache = vi.fn().mockResolvedValue(undefined);
await runBulkImport(
"https://example.com/",
fetchAndCache,
isSourceCached,
refreshCache,
);
expect(refreshCache).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest"; import { beforeAll, describe, expect, it } from "vitest";
import { normalizeBestiary } from "../bestiary-adapter.js"; import {
normalizeBestiary,
setSourceDisplayNames,
} from "../bestiary-adapter.js";
beforeAll(() => {
setSourceDisplayNames({ XMM: "MM 2024" });
});
describe("normalizeBestiary", () => { describe("normalizeBestiary", () => {
it("normalizes a simple creature", () => { it("normalizes a simple creature", () => {

View File

@@ -1,16 +0,0 @@
import { expect, it } from "vitest";
import rawData from "../../../../../data/bestiary/xmm.json";
import { normalizeBestiary } from "../bestiary-adapter.js";
it("normalizes all 503 monsters without error", () => {
const creatures = normalizeBestiary(
rawData as unknown as Parameters<typeof normalizeBestiary>[0],
);
expect(creatures.length).toBe(503);
for (const c of creatures) {
expect(c.name).toBeTruthy();
expect(c.id).toBeTruthy();
expect(c.ac).toBeGreaterThanOrEqual(0);
expect(c.hp.average).toBeGreaterThan(0);
}
});

View File

@@ -17,8 +17,8 @@ interface RawMonster {
size: string[]; size: string[];
type: string | { type: string; tags?: string[]; swarmSize?: string }; type: string | { type: string; tags?: string[]; swarmSize?: string };
alignment?: string[]; alignment?: string[];
ac: (number | { ac: number; from?: string[] })[]; ac: (number | { ac: number; from?: string[] } | { special: string })[];
hp: { average: number; formula: string }; hp: { average?: number; formula?: string; special?: string };
speed: Record< speed: Record<
string, string,
number | { number: number; condition?: string } | boolean number | { number: number; condition?: string } | boolean
@@ -38,7 +38,7 @@ interface RawMonster {
vulnerable?: (string | { special: string })[]; vulnerable?: (string | { special: string })[];
conditionImmune?: string[]; conditionImmune?: string[];
languages?: string[]; languages?: string[];
cr: string | { cr: string }; cr?: string | { cr: string };
trait?: RawEntry[]; trait?: RawEntry[];
action?: RawEntry[]; action?: RawEntry[];
bonus?: RawEntry[]; bonus?: RawEntry[];
@@ -81,9 +81,11 @@ interface RawSpellcasting {
// --- Source mapping --- // --- Source mapping ---
const SOURCE_DISPLAY_NAMES: Record<string, string> = { let sourceDisplayNames: Record<string, string> = {};
XMM: "MM 2024",
}; export function setSourceDisplayNames(names: Record<string, string>): void {
sourceDisplayNames = names;
}
// --- Size mapping --- // --- Size mapping ---
@@ -138,7 +140,12 @@ function formatType(
let result = baseType; let result = baseType;
if (type.tags && type.tags.length > 0) { if (type.tags && type.tags.length > 0) {
result += ` (${type.tags.map(capitalize).join(", ")})`; const tagStrs = type.tags
.filter((t): t is string => typeof t === "string")
.map(capitalize);
if (tagStrs.length > 0) {
result += ` (${tagStrs.join(", ")})`;
}
} }
if (type.swarmSize) { if (type.swarmSize) {
const swarmSizeLabel = SIZE_MAP[type.swarmSize] ?? type.swarmSize; const swarmSizeLabel = SIZE_MAP[type.swarmSize] ?? type.swarmSize;
@@ -159,6 +166,14 @@ function extractAc(ac: RawMonster["ac"]): {
if (typeof first === "number") { if (typeof first === "number") {
return { value: first }; return { value: first };
} }
if ("special" in first) {
// Variable AC (e.g. spell summons) — parse leading number if possible
const match = first.special.match(/^(\d+)/);
return {
value: match ? Number(match[1]) : 0,
source: first.special,
};
}
return { return {
value: first.ac, value: first.ac,
source: first.from ? stripTags(first.from.join(", ")) : undefined, source: first.from ? stripTags(first.from.join(", ")) : undefined,
@@ -239,26 +254,37 @@ function formatConditionImmunities(
.join(", "); .join(", ");
} }
function renderEntries(entries: (string | RawEntryObject)[]): string { function renderListItem(item: string | RawEntryObject): string | undefined {
const parts: string[] = [];
for (const entry of entries) {
if (typeof entry === "string") {
parts.push(stripTags(entry));
} else if (entry.type === "list") {
for (const item of entry.items ?? []) {
if (typeof item === "string") { if (typeof item === "string") {
parts.push(`${stripTags(item)}`); return `${stripTags(item)}`;
} else if (item.name && item.entries) {
parts.push(
`${stripTags(item.name)}: ${renderEntries(item.entries)}`,
);
} }
if (item.name && item.entries) {
return `${stripTags(item.name)}: ${renderEntries(item.entries)}`;
}
return undefined;
}
function renderEntryObject(entry: RawEntryObject, parts: string[]): void {
if (entry.type === "list") {
for (const item of entry.items ?? []) {
const rendered = renderListItem(item);
if (rendered) parts.push(rendered);
} }
} else if (entry.type === "item" && entry.name && entry.entries) { } else if (entry.type === "item" && entry.name && entry.entries) {
parts.push(`${stripTags(entry.name)}: ${renderEntries(entry.entries)}`); parts.push(`${stripTags(entry.name)}: ${renderEntries(entry.entries)}`);
} else if (entry.entries) { } else if (entry.entries) {
parts.push(renderEntries(entry.entries)); parts.push(renderEntries(entry.entries));
} }
}
function renderEntries(entries: (string | RawEntryObject)[]): string {
const parts: string[] = [];
for (const entry of entries) {
if (typeof entry === "string") {
parts.push(stripTags(entry));
} else {
renderEntryObject(entry, parts);
}
} }
return parts.join(" "); return parts.join(" ");
} }
@@ -337,7 +363,8 @@ function normalizeLegendary(
}; };
} }
function extractCr(cr: string | { cr: string }): string { function extractCr(cr: string | { cr: string } | undefined): string {
if (cr === undefined) return "—";
return typeof cr === "string" ? cr : cr.cr; return typeof cr === "string" ? cr : cr.cr;
} }
@@ -353,7 +380,32 @@ function makeCreatureId(source: string, name: string): CreatureId {
* Normalizes raw 5etools bestiary JSON into domain Creature[]. * Normalizes raw 5etools bestiary JSON into domain Creature[].
*/ */
export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] { export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
return raw.monster.map((m) => { // Filter out _copy entries (reference another source's monster) and
// monsters missing required fields (ac, hp, size, type)
const monsters = raw.monster.filter((m) => {
// biome-ignore lint/suspicious/noExplicitAny: raw JSON may have _copy field
if ((m as any)._copy) return false;
return (
Array.isArray(m.ac) &&
m.ac.length > 0 &&
m.hp !== undefined &&
Array.isArray(m.size) &&
m.size.length > 0 &&
m.type !== undefined
);
});
const creatures: Creature[] = [];
for (const m of monsters) {
try {
creatures.push(normalizeMonster(m));
} catch {
// Skip monsters with unexpected data shapes
}
}
return creatures;
}
function normalizeMonster(m: RawMonster): Creature {
const crStr = extractCr(m.cr); const crStr = extractCr(m.cr);
const ac = extractAc(m.ac); const ac = extractAc(m.ac);
@@ -361,13 +413,16 @@ export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
id: makeCreatureId(m.source, m.name), id: makeCreatureId(m.source, m.name),
name: m.name, name: m.name,
source: m.source, source: m.source,
sourceDisplayName: SOURCE_DISPLAY_NAMES[m.source] ?? m.source, sourceDisplayName: sourceDisplayNames[m.source] ?? m.source,
size: formatSize(m.size), size: formatSize(m.size),
type: formatType(m.type), type: formatType(m.type),
alignment: formatAlignment(m.alignment), alignment: formatAlignment(m.alignment),
ac: ac.value, ac: ac.value,
acSource: ac.source, acSource: ac.source,
hp: { average: m.hp.average, formula: m.hp.formula }, hp: {
average: m.hp.average ?? 0,
formula: m.hp.formula ?? m.hp.special ?? "",
},
speed: formatSpeed(m.speed), speed: formatSpeed(m.speed),
abilities: { abilities: {
str: m.str, str: m.str,
@@ -402,5 +457,4 @@ export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
legendaryActions: normalizeLegendary(m.legendary, m), legendaryActions: normalizeLegendary(m.legendary, m),
spellcasting: normalizeSpellcasting(m.spellcasting), spellcasting: normalizeSpellcasting(m.spellcasting),
}; };
});
} }

View File

@@ -0,0 +1,139 @@
import type { Creature, CreatureId } from "@initiative/domain";
import { type IDBPDatabase, openDB } from "idb";
const DB_NAME = "initiative-bestiary";
const STORE_NAME = "sources";
const DB_VERSION = 1;
export interface CachedSourceInfo {
readonly sourceCode: string;
readonly displayName: string;
readonly creatureCount: number;
readonly cachedAt: number;
}
interface CachedSourceRecord {
sourceCode: string;
displayName: string;
creatures: Creature[];
cachedAt: number;
creatureCount: number;
}
let db: IDBPDatabase | null = null;
let dbFailed = false;
// In-memory fallback when IndexedDB is unavailable
const memoryStore = new Map<string, CachedSourceRecord>();
async function getDb(): Promise<IDBPDatabase | null> {
if (db) return db;
if (dbFailed) return null;
try {
db = await openDB(DB_NAME, DB_VERSION, {
upgrade(database) {
if (!database.objectStoreNames.contains(STORE_NAME)) {
database.createObjectStore(STORE_NAME, {
keyPath: "sourceCode",
});
}
},
});
return db;
} catch {
dbFailed = true;
console.warn(
"IndexedDB unavailable — bestiary cache will not persist across sessions.",
);
return null;
}
}
export async function cacheSource(
sourceCode: string,
displayName: string,
creatures: Creature[],
): Promise<void> {
const record: CachedSourceRecord = {
sourceCode,
displayName,
creatures,
cachedAt: Date.now(),
creatureCount: creatures.length,
};
const database = await getDb();
if (database) {
await database.put(STORE_NAME, record);
} else {
memoryStore.set(sourceCode, record);
}
}
export async function isSourceCached(sourceCode: string): Promise<boolean> {
const database = await getDb();
if (database) {
const record = await database.get(STORE_NAME, sourceCode);
return record !== undefined;
}
return memoryStore.has(sourceCode);
}
export async function getCachedSources(): Promise<CachedSourceInfo[]> {
const database = await getDb();
if (database) {
const all: CachedSourceRecord[] = await database.getAll(STORE_NAME);
return all.map((r) => ({
sourceCode: r.sourceCode,
displayName: r.displayName,
creatureCount: r.creatureCount,
cachedAt: r.cachedAt,
}));
}
return [...memoryStore.values()].map((r) => ({
sourceCode: r.sourceCode,
displayName: r.displayName,
creatureCount: r.creatureCount,
cachedAt: r.cachedAt,
}));
}
export async function clearSource(sourceCode: string): Promise<void> {
const database = await getDb();
if (database) {
await database.delete(STORE_NAME, sourceCode);
} else {
memoryStore.delete(sourceCode);
}
}
export async function clearAll(): Promise<void> {
const database = await getDb();
if (database) {
await database.clear(STORE_NAME);
} else {
memoryStore.clear();
}
}
export async function loadAllCachedCreatures(): Promise<
Map<CreatureId, Creature>
> {
const map = new Map<CreatureId, Creature>();
const database = await getDb();
let records: CachedSourceRecord[];
if (database) {
records = await database.getAll(STORE_NAME);
} else {
records = [...memoryStore.values()];
}
for (const record of records) {
for (const creature of record.creatures) {
map.set(creature.id, creature);
}
}
return map;
}

View File

@@ -0,0 +1,96 @@
import type { BestiaryIndex, BestiaryIndexEntry } from "@initiative/domain";
import rawIndex from "../../../../data/bestiary/index.json";
interface CompactCreature {
readonly n: string;
readonly s: string;
readonly ac: number;
readonly hp: number;
readonly dx: number;
readonly cr: string;
readonly ip: number;
readonly sz: string;
readonly tp: string;
}
interface CompactIndex {
readonly sources: Record<string, string>;
readonly creatures: readonly CompactCreature[];
}
function mapCreature(c: CompactCreature): BestiaryIndexEntry {
return {
name: c.n,
source: c.s,
ac: c.ac,
hp: c.hp,
dex: c.dx,
cr: c.cr,
initiativeProficiency: c.ip,
size: c.sz,
type: c.tp,
};
}
// Source codes whose filename on the remote differs from a simple lowercase.
// Plane Shift sources use a hyphen: PSA -> ps-a, etc.
const FILENAME_OVERRIDES: Record<string, string> = {
PSA: "ps-a",
PSD: "ps-d",
PSI: "ps-i",
PSK: "ps-k",
PSX: "ps-x",
PSZ: "ps-z",
};
// Source codes with no corresponding remote bestiary file.
// Excluded from the index entirely so creatures aren't searchable
// without a fetchable source.
const EXCLUDED_SOURCES = new Set<string>([]);
let cachedIndex: BestiaryIndex | undefined;
export function loadBestiaryIndex(): BestiaryIndex {
if (cachedIndex) return cachedIndex;
const compact = rawIndex as unknown as CompactIndex;
const sources = Object.fromEntries(
Object.entries(compact.sources).filter(
([code]) => !EXCLUDED_SOURCES.has(code),
),
);
cachedIndex = {
sources,
creatures: compact.creatures
.filter((c) => !EXCLUDED_SOURCES.has(c.s))
.map(mapCreature),
};
return cachedIndex;
}
export function getAllSourceCodes(): string[] {
const index = loadBestiaryIndex();
return Object.keys(index.sources).filter((c) => !EXCLUDED_SOURCES.has(c));
}
function sourceCodeToFilename(sourceCode: string): string {
return FILENAME_OVERRIDES[sourceCode] ?? sourceCode.toLowerCase();
}
export function getDefaultFetchUrl(
sourceCode: string,
baseUrl?: string,
): string {
const filename = `bestiary-${sourceCodeToFilename(sourceCode)}.json`;
if (baseUrl !== undefined) {
const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
return `${normalized}${filename}`;
}
return `https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/${filename}`;
}
export function getSourceDisplayName(sourceCode: string): string {
const index = loadBestiaryIndex();
return index.sources[sourceCode] ?? sourceCode;
}

View File

@@ -20,6 +20,7 @@ const ATKR_MAP: Record<string, string> = {
* Handles 15+ tag types per research.md R-002 tag resolution rules. * Handles 15+ tag types per research.md R-002 tag resolution rules.
*/ */
export function stripTags(text: string): string { export function stripTags(text: string): string {
if (typeof text !== "string") return String(text);
// Process special tags with specific output formats first // Process special tags with specific output formats first
let result = text; let result = text;

View File

@@ -0,0 +1,219 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import type { Encounter } from "@initiative/domain";
import { combatantId } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TurnNavigation } from "../turn-navigation";
afterEach(cleanup);
function renderNav(overrides: Partial<Encounter> = {}) {
const encounter: Encounter = {
combatants: [
{ id: combatantId("1"), name: "Goblin" },
{ id: combatantId("2"), name: "Conjurer" },
],
activeIndex: 0,
roundNumber: 1,
...overrides,
};
return render(
<TurnNavigation
encounter={encounter}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>,
);
}
describe("TurnNavigation", () => {
describe("US1: Round badge and combatant name", () => {
it("renders the round badge with correct round number", () => {
renderNav({ roundNumber: 3 });
expect(screen.getByText("R3")).toBeInTheDocument();
});
it("renders the combatant name separately from the round badge", () => {
renderNav();
const badge = screen.getByText("R1");
const name = screen.getByText("Goblin");
expect(badge).toBeInTheDocument();
expect(name).toBeInTheDocument();
expect(badge).not.toBe(name);
expect(badge.closest("[class]")).not.toBe(name.closest("[class]"));
});
it("does not render an em dash between round and name", () => {
const { container } = renderNav();
expect(container.textContent).not.toContain("—");
});
it("round badge and combatant name are in separate DOM elements", () => {
renderNav();
const badge = screen.getByText("R1");
const name = screen.getByText("Goblin");
expect(badge.parentElement).not.toBe(name.parentElement);
});
it("updates the round badge when round changes", () => {
const { rerender } = render(
<TurnNavigation
encounter={{
combatants: [{ id: combatantId("1"), name: "Goblin" }],
activeIndex: 0,
roundNumber: 2,
}}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>,
);
expect(screen.getByText("R2")).toBeInTheDocument();
rerender(
<TurnNavigation
encounter={{
combatants: [{ id: combatantId("1"), name: "Goblin" }],
activeIndex: 0,
roundNumber: 3,
}}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>,
);
expect(screen.getByText("R3")).toBeInTheDocument();
expect(screen.queryByText("R2")).not.toBeInTheDocument();
});
it("renders the next combatant name when turn advances", () => {
const { rerender } = render(
<TurnNavigation
encounter={{
combatants: [
{ id: combatantId("1"), name: "Goblin" },
{ id: combatantId("2"), name: "Conjurer" },
],
activeIndex: 0,
roundNumber: 1,
}}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>,
);
expect(screen.getByText("Goblin")).toBeInTheDocument();
rerender(
<TurnNavigation
encounter={{
combatants: [
{ id: combatantId("1"), name: "Goblin" },
{ id: combatantId("2"), name: "Conjurer" },
],
activeIndex: 1,
roundNumber: 1,
}}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>,
);
expect(screen.getByText("Conjurer")).toBeInTheDocument();
});
});
describe("US2: Layout robustness", () => {
it("applies truncation styles to long combatant names", () => {
const longName =
"Ancient Red Dragon Wyrm of the Northern Wastes and Beyond";
renderNav({
combatants: [{ id: combatantId("1"), name: longName }],
});
const nameEl = screen.getByText(longName);
expect(nameEl.className).toContain("truncate");
});
it("renders three-zone layout with a single-character name", () => {
renderNav({
combatants: [{ id: combatantId("1"), name: "O" }],
});
expect(screen.getByText("R1")).toBeInTheDocument();
expect(screen.getByText("O")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Previous turn" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Next turn" }),
).toBeInTheDocument();
});
it("keeps all action buttons accessible regardless of name length", () => {
const longName = "A".repeat(60);
renderNav({
combatants: [{ id: combatantId("1"), name: longName }],
});
expect(
screen.getByRole("button", { name: "Previous turn" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Next turn" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", {
name: "Roll all initiative",
}),
).toBeInTheDocument();
expect(
screen.getByRole("button", {
name: "Manage cached sources",
}),
).toBeInTheDocument();
});
it("renders a 40-character name without truncation class issues", () => {
const name40 = "A".repeat(40);
renderNav({
combatants: [{ id: combatantId("1"), name: name40 }],
});
const nameEl = screen.getByText(name40);
expect(nameEl).toBeInTheDocument();
// The truncate class is applied but CSS only visually truncates if content overflows
expect(nameEl.className).toContain("truncate");
});
});
describe("US3: No combatants state", () => {
it("shows the round badge when there are no combatants", () => {
renderNav({ combatants: [], roundNumber: 1 });
expect(screen.getByText("R1")).toBeInTheDocument();
});
it("shows 'No combatants' placeholder text", () => {
renderNav({ combatants: [] });
expect(screen.getByText("No combatants")).toBeInTheDocument();
});
it("disables navigation buttons when there are no combatants", () => {
renderNav({ combatants: [] });
expect(
screen.getByRole("button", { name: "Previous turn" }),
).toBeDisabled();
expect(screen.getByRole("button", { name: "Next turn" })).toBeDisabled();
});
});
});

View File

@@ -1,18 +1,29 @@
import type { Creature } from "@initiative/domain"; import { Check, Eye, Import, Minus, Plus } from "lucide-react";
import { Search } from "lucide-react"; import { type FormEvent, useEffect, useRef, useState } from "react";
import { type FormEvent, useState } from "react"; import type { SearchResult } from "../hooks/use-bestiary.js";
import { BestiarySearch } from "./bestiary-search.js";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js"; import { Input } from "./ui/input.js";
interface QueuedCreature {
result: SearchResult;
count: number;
}
interface ActionBarProps { interface ActionBarProps {
onAddCombatant: (name: string) => void; onAddCombatant: (
onAddFromBestiary: (creature: Creature) => void; name: string,
bestiarySearch: (query: string) => Creature[]; opts?: { initiative?: number; ac?: number; maxHp?: number },
) => void;
onAddFromBestiary: (result: SearchResult) => void;
bestiarySearch: (query: string) => SearchResult[];
bestiaryLoaded: boolean; bestiaryLoaded: boolean;
suggestions: Creature[]; onViewStatBlock?: (result: SearchResult) => void;
onSearchChange: (query: string) => void; onBulkImport?: () => void;
onShowStatBlock?: (creature: Creature) => void; bulkImportDisabled?: boolean;
}
function creatureKey(r: SearchResult): string {
return `${r.source}:${r.name}`;
} }
export function ActionBar({ export function ActionBar({
@@ -20,41 +31,106 @@ export function ActionBar({
onAddFromBestiary, onAddFromBestiary,
bestiarySearch, bestiarySearch,
bestiaryLoaded, bestiaryLoaded,
suggestions, onViewStatBlock,
onSearchChange, onBulkImport,
onShowStatBlock, bulkImportDisabled,
}: ActionBarProps) { }: ActionBarProps) {
const [nameInput, setNameInput] = useState(""); const [nameInput, setNameInput] = useState("");
const [searchOpen, setSearchOpen] = useState(false); const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
const [suggestionIndex, setSuggestionIndex] = useState(-1); const [suggestionIndex, setSuggestionIndex] = useState(-1);
const [queued, setQueued] = useState<QueuedCreature | null>(null);
const [customInit, setCustomInit] = useState("");
const [customAc, setCustomAc] = useState("");
const [customMaxHp, setCustomMaxHp] = useState("");
// Stat block viewer: separate dropdown
const [viewerOpen, setViewerOpen] = useState(false);
const [viewerQuery, setViewerQuery] = useState("");
const [viewerResults, setViewerResults] = useState<SearchResult[]>([]);
const [viewerIndex, setViewerIndex] = useState(-1);
const viewerRef = useRef<HTMLDivElement>(null);
const viewerInputRef = useRef<HTMLInputElement>(null);
const clearCustomFields = () => {
setCustomInit("");
setCustomAc("");
setCustomMaxHp("");
};
const confirmQueued = () => {
if (!queued) return;
for (let i = 0; i < queued.count; i++) {
onAddFromBestiary(queued.result);
}
setQueued(null);
setNameInput("");
setSuggestions([]);
setSuggestionIndex(-1);
};
const parseNum = (v: string): number | undefined => {
if (v.trim() === "") return undefined;
const n = Number(v);
return Number.isNaN(n) ? undefined : n;
};
const handleAdd = (e: FormEvent) => { const handleAdd = (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
if (queued) {
confirmQueued();
return;
}
if (nameInput.trim() === "") return; if (nameInput.trim() === "") return;
onAddCombatant(nameInput); const opts: { initiative?: number; ac?: number; maxHp?: number } = {};
const init = parseNum(customInit);
const ac = parseNum(customAc);
const maxHp = parseNum(customMaxHp);
if (init !== undefined) opts.initiative = init;
if (ac !== undefined) opts.ac = ac;
if (maxHp !== undefined) opts.maxHp = maxHp;
onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
setNameInput(""); setNameInput("");
onSearchChange(""); setSuggestions([]);
clearCustomFields();
}; };
const handleNameChange = (value: string) => { const handleNameChange = (value: string) => {
setNameInput(value); setNameInput(value);
setSuggestionIndex(-1); setSuggestionIndex(-1);
onSearchChange(value); let newSuggestions: SearchResult[] = [];
if (value.length >= 2) {
newSuggestions = bestiarySearch(value);
setSuggestions(newSuggestions);
} else {
setSuggestions([]);
}
if (newSuggestions.length > 0) {
clearCustomFields();
}
if (queued) {
const qKey = creatureKey(queued.result);
const stillVisible = newSuggestions.some((s) => creatureKey(s) === qKey);
if (!stillVisible) {
setQueued(null);
}
}
}; };
const handleSelectCreature = (creature: Creature) => { const handleClickSuggestion = (result: SearchResult) => {
onAddFromBestiary(creature); const key = creatureKey(result);
setSearchOpen(false); if (queued && creatureKey(queued.result) === key) {
setNameInput(""); setQueued({ ...queued, count: queued.count + 1 });
onSearchChange(""); } else {
onShowStatBlock?.(creature); setQueued({ result, count: 1 });
}
}; };
const handleSelectSuggestion = (creature: Creature) => { const handleEnter = () => {
onAddFromBestiary(creature); if (queued) {
setNameInput(""); confirmQueued();
onSearchChange(""); } else if (suggestionIndex >= 0) {
onShowStatBlock?.(creature); handleClickSuggestion(suggestions[suggestionIndex]);
}
}; };
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -66,24 +142,80 @@ export function ActionBar({
} else if (e.key === "ArrowUp") { } else if (e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1)); setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
} else if (e.key === "Enter" && suggestionIndex >= 0) { } else if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
handleSelectSuggestion(suggestions[suggestionIndex]); handleEnter();
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
setQueued(null);
setSuggestionIndex(-1); setSuggestionIndex(-1);
onSearchChange(""); setSuggestions([]);
} }
}; };
// Stat block viewer dropdown handlers
const openViewer = () => {
setViewerOpen(true);
setViewerQuery("");
setViewerResults([]);
setViewerIndex(-1);
requestAnimationFrame(() => viewerInputRef.current?.focus());
};
const closeViewer = () => {
setViewerOpen(false);
setViewerQuery("");
setViewerResults([]);
setViewerIndex(-1);
};
const handleViewerQueryChange = (value: string) => {
setViewerQuery(value);
setViewerIndex(-1);
if (value.length >= 2) {
setViewerResults(bestiarySearch(value));
} else {
setViewerResults([]);
}
};
const handleViewerSelect = (result: SearchResult) => {
onViewStatBlock?.(result);
closeViewer();
};
const handleViewerKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
closeViewer();
return;
}
if (viewerResults.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setViewerIndex((i) => (i < viewerResults.length - 1 ? i + 1 : 0));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setViewerIndex((i) => (i > 0 ? i - 1 : viewerResults.length - 1));
} else if (e.key === "Enter" && viewerIndex >= 0) {
e.preventDefault();
handleViewerSelect(viewerResults[viewerIndex]);
}
};
// Close viewer on outside click
useEffect(() => {
if (!viewerOpen) return;
function handleClickOutside(e: MouseEvent) {
if (viewerRef.current && !viewerRef.current.contains(e.target as Node)) {
closeViewer();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [viewerOpen]);
return ( return (
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3"> <div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
{searchOpen ? (
<BestiarySearch
onSelectCreature={handleSelectCreature}
onClose={() => setSearchOpen(false)}
searchFn={bestiarySearch}
/>
) : (
<form <form
onSubmit={handleAdd} onSubmit={handleAdd}
className="relative flex flex-1 items-center gap-2" className="relative flex flex-1 items-center gap-2"
@@ -94,50 +226,192 @@ export function ActionBar({
value={nameInput} value={nameInput}
onChange={(e) => handleNameChange(e.target.value)} onChange={(e) => handleNameChange(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Combatant name" placeholder="+ Add combatants"
className="max-w-xs" className="max-w-xs"
/> />
{suggestions.length > 0 && ( {suggestions.length > 0 && (
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg"> <div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
<ul className="max-h-48 overflow-y-auto py-1"> <ul className="max-h-48 overflow-y-auto py-1">
{suggestions.map((creature, i) => ( {suggestions.map((result, i) => {
<li key={creature.id}> const key = creatureKey(result);
const isQueued =
queued !== null && creatureKey(queued.result) === key;
return (
<li key={key}>
<button <button
type="button" type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${ className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
i === suggestionIndex isQueued
? "bg-accent/30 text-foreground"
: i === suggestionIndex
? "bg-accent/20 text-foreground" ? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg" : "text-foreground hover:bg-hover-neutral-bg"
}`} }`}
onClick={() => handleSelectSuggestion(creature)} onMouseDown={(e) => e.preventDefault()}
onClick={() => handleClickSuggestion(result)}
onMouseEnter={() => setSuggestionIndex(i)} onMouseEnter={() => setSuggestionIndex(i)}
> >
<span>{creature.name}</span> <span>{result.name}</span>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{isQueued ? (
<>
<button
type="button"
className="rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
if (queued.count <= 1) {
setQueued(null);
} else {
setQueued({
...queued,
count: queued.count - 1,
});
}
}}
>
<Minus className="h-3 w-3" />
</button>
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-foreground">
{queued.count}
</span>
<button
type="button"
className="rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
setQueued({
...queued,
count: queued.count + 1,
});
}}
>
<Plus className="h-3 w-3" />
</button>
<button
type="button"
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
confirmQueued();
}}
>
<Check className="h-3.5 w-3.5" />
</button>
</>
) : (
result.sourceDisplayName
)}
</span>
</button>
</li>
);
})}
</ul>
</div>
)}
</div>
{nameInput.length >= 2 && suggestions.length === 0 && (
<div className="flex items-center gap-2">
<Input
type="text"
inputMode="numeric"
value={customInit}
onChange={(e) => setCustomInit(e.target.value)}
placeholder="Init"
className="w-16 text-center"
/>
<Input
type="text"
inputMode="numeric"
value={customAc}
onChange={(e) => setCustomAc(e.target.value)}
placeholder="AC"
className="w-16 text-center"
/>
<Input
type="text"
inputMode="numeric"
value={customMaxHp}
onChange={(e) => setCustomMaxHp(e.target.value)}
placeholder="MaxHP"
className="w-18 text-center"
/>
</div>
)}
<Button type="submit" size="sm">
Add
</Button>
{bestiaryLoaded && onViewStatBlock && (
<div ref={viewerRef} className="relative">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => (viewerOpen ? closeViewer() : openViewer())}
>
<Eye className="h-4 w-4" />
</Button>
{viewerOpen && (
<div className="absolute bottom-full right-0 z-50 mb-1 w-64 rounded-md border border-border bg-card shadow-lg">
<div className="p-2">
<Input
ref={viewerInputRef}
type="text"
value={viewerQuery}
onChange={(e) => handleViewerQueryChange(e.target.value)}
onKeyDown={handleViewerKeyDown}
placeholder="Search stat blocks..."
className="w-full"
/>
</div>
{viewerResults.length > 0 && (
<ul className="max-h-48 overflow-y-auto border-t border-border py-1">
{viewerResults.map((result, i) => (
<li key={creatureKey(result)}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
i === viewerIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
}`}
onClick={() => handleViewerSelect(result)}
onMouseEnter={() => setViewerIndex(i)}
>
<span>{result.name}</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{creature.sourceDisplayName} {result.sourceDisplayName}
</span> </span>
</button> </button>
</li> </li>
))} ))}
</ul> </ul>
)}
{viewerQuery.length >= 2 && viewerResults.length === 0 && (
<div className="border-t border-border px-3 py-2 text-sm text-muted-foreground">
No creatures found
</div> </div>
)} )}
</div> </div>
<Button type="submit" size="sm"> )}
Add </div>
</Button> )}
{bestiaryLoaded && ( {bestiaryLoaded && onBulkImport && (
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => setSearchOpen(true)} onClick={onBulkImport}
disabled={bulkImportDisabled}
> >
<Search className="h-4 w-4" /> <Import className="h-4 w-4" />
</Button> </Button>
)} )}
</form> </form>
)}
</div> </div>
); );
} }

View File

@@ -1,129 +0,0 @@
import type { Creature } from "@initiative/domain";
import { Search, X } from "lucide-react";
import {
type KeyboardEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { Input } from "./ui/input.js";
interface BestiarySearchProps {
onSelectCreature: (creature: Creature) => void;
onClose: () => void;
searchFn: (query: string) => Creature[];
}
export function BestiarySearch({
onSelectCreature,
onClose,
searchFn,
}: BestiarySearchProps) {
const [query, setQuery] = useState("");
const [highlightIndex, setHighlightIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const results = query.length >= 2 ? searchFn(query) : [];
useEffect(() => {
inputRef.current?.focus();
}, []);
useEffect(() => {
setHighlightIndex(-1);
}, [query]);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlightIndex((i) => (i < results.length - 1 ? i + 1 : 0));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setHighlightIndex((i) => (i > 0 ? i - 1 : results.length - 1));
return;
}
if (e.key === "Enter" && highlightIndex >= 0) {
e.preventDefault();
onSelectCreature(results[highlightIndex]);
}
},
[results, highlightIndex, onClose, onSelectCreature],
);
return (
<div ref={containerRef} className="relative w-full max-w-sm">
<div className="flex items-center gap-2">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search bestiary..."
className="flex-1"
/>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-hover-neutral"
>
<X className="h-4 w-4" />
</button>
</div>
{query.length >= 2 && (
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
{results.length === 0 ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
No creatures found
</div>
) : (
<ul className="max-h-60 overflow-y-auto py-1">
{results.map((creature, i) => (
<li key={creature.id}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
i === highlightIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
}`}
onClick={() => onSelectCreature(creature)}
onMouseEnter={() => setHighlightIndex(i)}
>
<span>{creature.name}</span>
<span className="text-xs text-muted-foreground">
{creature.sourceDisplayName}
</span>
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,115 @@
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js";
import type { BulkImportState } from "../hooks/use-bulk-import.js";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
const DEFAULT_BASE_URL =
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
interface BulkImportPromptProps {
importState: BulkImportState;
onStartImport: (baseUrl: string) => void;
onDone: () => void;
}
export function BulkImportPrompt({
importState,
onStartImport,
onDone,
}: BulkImportPromptProps) {
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
const totalSources = getAllSourceCodes().length;
if (importState.status === "complete") {
return (
<div className="flex flex-col gap-4">
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-sm text-green-400">
All sources loaded
</div>
<Button size="sm" onClick={onDone}>
Done
</Button>
</div>
);
}
if (importState.status === "partial-failure") {
return (
<div className="flex flex-col gap-4">
<div className="rounded-md border border-yellow-500/50 bg-yellow-500/10 px-3 py-2 text-sm text-yellow-400">
Loaded {importState.completed}/{importState.total} sources (
{importState.failed} failed)
</div>
<Button size="sm" onClick={onDone}>
Done
</Button>
</div>
);
}
if (importState.status === "loading") {
const processed = importState.completed + importState.failed;
const pct =
importState.total > 0
? Math.round((processed / importState.total) * 100)
: 0;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading sources... {processed}/{importState.total}
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
// idle state
const isDisabled = !baseUrl.trim() || importState.status !== "idle";
return (
<div className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-foreground">
Bulk Import Sources
</h3>
<p className="mt-1 text-xs text-muted-foreground">
Load stat block data for all {totalSources} sources at once. This will
download approximately 12.5 MB of data.
</p>
</div>
<div className="flex flex-col gap-2">
<label
htmlFor="bulk-base-url"
className="text-xs text-muted-foreground"
>
Base URL
</label>
<Input
id="bulk-base-url"
type="url"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
className="text-xs"
/>
</div>
<Button
size="sm"
onClick={() => onStartImport(baseUrl)}
disabled={isDisabled}
>
Load All
</Button>
</div>
);
}

View File

@@ -11,7 +11,7 @@ import { ConditionPicker } from "./condition-picker";
import { ConditionTags } from "./condition-tags"; import { ConditionTags } from "./condition-tags";
import { D20Icon } from "./d20-icon"; import { D20Icon } from "./d20-icon";
import { HpAdjustPopover } from "./hp-adjust-popover"; import { HpAdjustPopover } from "./hp-adjust-popover";
import { Button } from "./ui/button"; import { ConfirmButton } from "./ui/confirm-button";
import { Input } from "./ui/input"; import { Input } from "./ui/input";
interface Combatant { interface Combatant {
@@ -44,14 +44,19 @@ function EditableName({
name, name,
combatantId, combatantId,
onRename, onRename,
onShowStatBlock,
}: { }: {
name: string; name: string;
combatantId: CombatantId; combatantId: CombatantId;
onRename: (id: CombatantId, newName: string) => void; onRename: (id: CombatantId, newName: string) => void;
onShowStatBlock?: () => void;
}) { }) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(name); const [draft, setDraft] = useState(name);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const longPressTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const longPressTriggeredRef = useRef(false);
const commit = useCallback(() => { const commit = useCallback(() => {
const trimmed = draft.trim(); const trimmed = draft.trim();
@@ -67,6 +72,46 @@ function EditableName({
requestAnimationFrame(() => inputRef.current?.select()); requestAnimationFrame(() => inputRef.current?.select());
}, [name]); }, [name]);
useEffect(() => {
return () => {
clearTimeout(clickTimerRef.current);
clearTimeout(longPressTimerRef.current);
};
}, []);
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (longPressTriggeredRef.current) {
longPressTriggeredRef.current = false;
return;
}
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
clickTimerRef.current = undefined;
startEditing();
} else {
clickTimerRef.current = setTimeout(() => {
clickTimerRef.current = undefined;
onShowStatBlock?.();
}, 250);
}
},
[startEditing, onShowStatBlock],
);
const handleTouchStart = useCallback(() => {
longPressTriggeredRef.current = false;
longPressTimerRef.current = setTimeout(() => {
longPressTriggeredRef.current = true;
startEditing();
}, 500);
}, [startEditing]);
const cancelLongPress = useCallback(() => {
clearTimeout(longPressTimerRef.current);
}, []);
if (editing) { if (editing) {
return ( return (
<Input <Input
@@ -85,16 +130,19 @@ function EditableName({
} }
return ( return (
<>
<button <button
type="button" type="button"
onClick={(e) => { onClick={handleClick}
e.stopPropagation(); onTouchStart={handleTouchStart}
startEditing(); onTouchEnd={cancelLongPress}
}} onTouchCancel={cancelLongPress}
className="truncate text-left text-sm text-foreground hover:text-hover-neutral transition-colors" onTouchMove={cancelLongPress}
className="truncate text-left text-sm text-foreground cursor-text hover:text-hover-neutral transition-colors"
> >
{name} {name}
</button> </button>
</>
); );
} }
@@ -354,6 +402,35 @@ function InitiativeDisplay({
); );
} }
function rowBorderClass(
isActive: boolean,
isConcentrating: boolean | undefined,
): string {
if (isActive) return "border-l-2 border-l-accent bg-accent/10";
if (isConcentrating) return "border-l-2 border-l-purple-400";
return "border-l-2 border-l-transparent";
}
function concentrationIconClass(
isConcentrating: boolean | undefined,
dimmed: boolean,
): string {
if (!isConcentrating)
return "opacity-0 group-hover:opacity-50 text-muted-foreground";
return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400";
}
function activateOnKeyDown(
handler: () => void,
): (e: { key: string; preventDefault: () => void }) => void {
return (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handler();
}
};
}
export function CombatantRow({ export function CombatantRow({
ref, ref,
combatant, combatant,
@@ -402,21 +479,21 @@ export function CombatantRow({
}, [combatant.isConcentrating]); }, [combatant.isConcentrating]);
return ( return (
/* biome-ignore lint/a11y/useKeyWithClickEvents: row click opens stat block */ /* biome-ignore lint/a11y/noStaticElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
/* biome-ignore lint/a11y/noStaticElementInteractions: row click opens stat block */
<div <div
ref={ref} ref={ref}
role={onShowStatBlock ? "button" : undefined}
tabIndex={onShowStatBlock ? 0 : undefined}
className={cn( className={cn(
"group rounded-md pr-3 transition-colors", "group rounded-md pr-3 transition-colors",
isActive rowBorderClass(isActive, combatant.isConcentrating),
? "border-l-2 border-l-accent bg-accent/10"
: combatant.isConcentrating
? "border-l-2 border-l-purple-400"
: "border-l-2 border-l-transparent",
isPulsing && "animate-concentration-pulse", isPulsing && "animate-concentration-pulse",
onShowStatBlock && "cursor-pointer", onShowStatBlock && "cursor-pointer",
)} )}
onClick={onShowStatBlock} onClick={onShowStatBlock}
onKeyDown={
onShowStatBlock ? activateOnKeyDown(onShowStatBlock) : undefined
}
> >
<div className="grid grid-cols-[2rem_3rem_1fr_auto_auto_2rem] items-center gap-3 py-2"> <div className="grid grid-cols-[2rem_3rem_1fr_auto_auto_2rem] items-center gap-3 py-2">
{/* Concentration */} {/* Concentration */}
@@ -430,20 +507,18 @@ export function CombatantRow({
aria-label="Toggle concentration" aria-label="Toggle concentration"
className={cn( className={cn(
"flex w-full items-center justify-center self-stretch -my-2 -ml-[2px] pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100", "flex w-full items-center justify-center self-stretch -my-2 -ml-[2px] pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
combatant.isConcentrating concentrationIconClass(combatant.isConcentrating, dimmed),
? dimmed
? "opacity-50 text-purple-400"
: "opacity-100 text-purple-400"
: "opacity-0 group-hover:opacity-50 text-muted-foreground",
)} )}
> >
<Brain size={16} /> <Brain size={16} />
</button> </button>
{/* Initiative */} {/* Initiative */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation wrapper */} {/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper */} <div
<div onClick={(e) => e.stopPropagation()}> onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<InitiativeDisplay <InitiativeDisplay
initiative={initiative} initiative={initiative}
combatantId={id} combatantId={id}
@@ -460,9 +535,12 @@ export function CombatantRow({
dimmed && "opacity-50", dimmed && "opacity-50",
)} )}
> >
<span className="min-w-0 truncate"> <EditableName
<EditableName name={name} combatantId={id} onRename={onRename} /> name={name}
</span> combatantId={id}
onRename={onRename}
onShowStatBlock={onShowStatBlock}
/>
<ConditionTags <ConditionTags
conditions={combatant.conditions} conditions={combatant.conditions}
onRemove={(conditionId) => onToggleCondition(id, conditionId)} onRemove={(conditionId) => onToggleCondition(id, conditionId)}
@@ -478,21 +556,21 @@ export function CombatantRow({
</div> </div>
{/* AC */} {/* AC */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation wrapper */} {/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper */}
<div <div
className={cn(dimmed && "opacity-50")} className={cn(dimmed && "opacity-50")}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
> >
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} /> <AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
</div> </div>
{/* HP */} {/* HP */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation wrapper */} {/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper */}
<div <div
className="flex items-center gap-1" className="flex items-center gap-1"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
> >
<ClickableHp <ClickableHp
currentHp={currentHp} currentHp={currentHp}
@@ -516,19 +594,12 @@ export function CombatantRow({
</div> </div>
{/* Actions */} {/* Actions */}
<Button <ConfirmButton
variant="ghost" icon={<X size={16} />}
size="icon" label="Remove combatant"
className="h-7 w-7 text-muted-foreground hover:text-hover-destructive opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus:opacity-100 focus:pointer-events-auto transition-opacity" onConfirm={() => onRemove(id)}
onClick={(e) => { className="h-7 w-7 text-muted-foreground opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus:opacity-100 focus:pointer-events-auto pointer-coarse:opacity-100 pointer-coarse:pointer-events-auto transition-opacity"
e.stopPropagation(); />
onRemove(id);
}}
title="Remove combatant"
aria-label="Remove combatant"
>
<X size={16} />
</Button>
</div> </div>
</div> </div>
); );

View File

@@ -64,12 +64,21 @@ export function ConditionPicker({
}: ConditionPickerProps) { }: ConditionPickerProps) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [flipped, setFlipped] = useState(false); const [flipped, setFlipped] = useState(false);
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
useLayoutEffect(() => { useLayoutEffect(() => {
const el = ref.current; const el = ref.current;
if (!el) return; if (!el) return;
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
setFlipped(rect.bottom > window.innerHeight); const spaceBelow = window.innerHeight - rect.top;
const spaceAbove = rect.bottom;
const shouldFlip =
rect.bottom > window.innerHeight && spaceAbove > spaceBelow;
setFlipped(shouldFlip);
const available = shouldFlip ? spaceAbove : spaceBelow;
if (rect.height > available) {
setMaxHeight(available - 16);
}
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -88,9 +97,10 @@ export function ConditionPicker({
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"absolute z-10 w-fit rounded-md border border-border bg-background p-1 shadow-lg", "absolute left-0 z-10 w-fit overflow-y-auto rounded-md border border-border bg-background p-1 shadow-lg",
flipped ? "bottom-full mb-1" : "mt-1", flipped ? "bottom-full mb-1" : "top-full mt-1",
)} )}
style={maxHeight ? { maxHeight } : undefined}
> >
{CONDITION_DEFINITIONS.map((def) => { {CONDITION_DEFINITIONS.map((def) => {
const Icon = ICON_MAP[def.iconName]; const Icon = ICON_MAP[def.iconName];

View File

@@ -89,7 +89,7 @@ export function ConditionTags({
type="button" type="button"
title="Add condition" title="Add condition"
aria-label="Add condition" aria-label="Add condition"
className="inline-flex items-center rounded p-0.5 text-muted-foreground hover:text-hover-neutral hover:bg-hover-neutral-bg transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity" className="inline-flex items-center rounded p-0.5 text-muted-foreground hover:text-hover-neutral hover:bg-hover-neutral-bg transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 pointer-coarse:opacity-100 transition-opacity"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onOpenPicker(); onOpenPicker();

View File

@@ -0,0 +1,131 @@
import { Download, Loader2, Upload } from "lucide-react";
import { useRef, useState } from "react";
import { getDefaultFetchUrl } from "../adapters/bestiary-index-adapter.js";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
interface SourceFetchPromptProps {
sourceCode: string;
sourceDisplayName: string;
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
onSourceLoaded: () => void;
onUploadSource: (sourceCode: string, jsonData: unknown) => Promise<void>;
}
export function SourceFetchPrompt({
sourceCode,
sourceDisplayName,
fetchAndCacheSource,
onSourceLoaded,
onUploadSource,
}: SourceFetchPromptProps) {
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
const [error, setError] = useState<string>("");
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFetch = async () => {
setStatus("fetching");
setError("");
try {
await fetchAndCacheSource(sourceCode, url);
onSourceLoaded();
} catch (e) {
setStatus("error");
setError(e instanceof Error ? e.message : "Failed to fetch source data");
}
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setStatus("fetching");
setError("");
try {
const text = await file.text();
const json = JSON.parse(text);
await onUploadSource(sourceCode, json);
onSourceLoaded();
} catch (err) {
setStatus("error");
setError(
err instanceof Error ? err.message : "Failed to process uploaded file",
);
}
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
return (
<div className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-foreground">
Load {sourceDisplayName}
</h3>
<p className="mt-1 text-xs text-muted-foreground">
Stat block data for this source needs to be loaded. Enter a URL or
upload a JSON file.
</p>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="source-url" className="text-xs text-muted-foreground">
Source URL
</label>
<Input
id="source-url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
disabled={status === "fetching"}
className="text-xs"
/>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={handleFetch}
disabled={status === "fetching" || !url}
>
{status === "fetching" ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Download className="mr-1 h-3 w-3" />
)}
{status === "fetching" ? "Loading..." : "Load"}
</Button>
<span className="text-xs text-muted-foreground">or</span>
<Button
size="sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={status === "fetching"}
>
<Upload className="mr-1 h-3 w-3" />
Upload file
</Button>
<input
ref={fileInputRef}
type="file"
accept=".json"
className="hidden"
onChange={handleFileUpload}
/>
</div>
{status === "error" && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{error}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { Database, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
import * as bestiaryCache from "../adapters/bestiary-cache.js";
import { Button } from "./ui/button.js";
interface SourceManagerProps {
onCacheCleared: () => void;
}
export function SourceManager({ onCacheCleared }: SourceManagerProps) {
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
const loadSources = useCallback(async () => {
const cached = await bestiaryCache.getCachedSources();
setSources(cached);
}, []);
useEffect(() => {
loadSources();
}, [loadSources]);
const handleClearSource = async (sourceCode: string) => {
await bestiaryCache.clearSource(sourceCode);
await loadSources();
onCacheCleared();
};
const handleClearAll = async () => {
await bestiaryCache.clearAll();
await loadSources();
onCacheCleared();
};
if (sources.length === 0) {
return (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<Database className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No cached sources</p>
</div>
);
}
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-foreground">
Cached Sources
</span>
<Button size="sm" variant="outline" onClick={handleClearAll}>
<Trash2 className="mr-1 h-3 w-3" />
Clear All
</Button>
</div>
<ul className="flex flex-col gap-1">
{sources.map((source) => (
<li
key={source.sourceCode}
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
>
<div>
<span className="text-sm text-foreground">
{source.displayName}
</span>
<span className="ml-2 text-xs text-muted-foreground">
{source.creatureCount} creatures
</span>
</div>
<button
type="button"
onClick={() => handleClearSource(source.sourceCode)}
className="text-muted-foreground hover:text-hover-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
</div>
);
}

View File

@@ -1,17 +1,242 @@
import type { Creature } from "@initiative/domain"; import type { Creature, CreatureId } from "@initiative/domain";
import { X } from "lucide-react"; import { PanelRightClose, Pin, PinOff } from "lucide-react";
import type { ReactNode } from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
import type { BulkImportState } from "../hooks/use-bulk-import.js";
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
import { BulkImportPrompt } from "./bulk-import-prompt.js";
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
import { StatBlock } from "./stat-block.js"; import { StatBlock } from "./stat-block.js";
interface StatBlockPanelProps { interface StatBlockPanelProps {
creatureId: CreatureId | null;
creature: Creature | null; creature: Creature | null;
onClose: () => void; isSourceCached: (sourceCode: string) => Promise<boolean>;
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
uploadAndCacheSource: (
sourceCode: string,
jsonData: unknown,
) => Promise<void>;
refreshCache: () => Promise<void>;
panelRole: "browse" | "pinned";
isFolded: boolean;
onToggleFold: () => void;
onPin: () => void;
onUnpin: () => void;
showPinButton: boolean;
side: "left" | "right";
onDismiss: () => void;
bulkImportMode?: boolean;
bulkImportState?: BulkImportState;
onStartBulkImport?: (baseUrl: string) => void;
onBulkImportDone?: () => void;
} }
export function StatBlockPanel({ creature, onClose }: StatBlockPanelProps) { function extractSourceCode(cId: CreatureId): string {
const colonIndex = cId.indexOf(":");
if (colonIndex === -1) return "";
return cId.slice(0, colonIndex).toUpperCase();
}
function FoldedTab({
creatureName,
side,
onToggleFold,
}: {
creatureName: string;
side: "left" | "right";
onToggleFold: () => void;
}) {
return (
<button
type="button"
onClick={onToggleFold}
className={`flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral ${
side === "right" ? "self-start" : "self-end"
}`}
aria-label="Unfold stat block panel"
>
<span className="writing-vertical-rl text-sm font-medium">
{creatureName}
</span>
</button>
);
}
function PanelHeader({
panelRole,
showPinButton,
onToggleFold,
onPin,
onUnpin,
}: {
panelRole: "browse" | "pinned";
showPinButton: boolean;
onToggleFold: () => void;
onPin: () => void;
onUnpin: () => void;
}) {
return (
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex items-center gap-1">
{panelRole === "browse" && (
<button
type="button"
onClick={onToggleFold}
className="text-muted-foreground hover:text-hover-neutral"
aria-label="Fold stat block panel"
>
<PanelRightClose className="h-4 w-4" />
</button>
)}
</div>
<div className="flex items-center gap-1">
{panelRole === "browse" && showPinButton && (
<button
type="button"
onClick={onPin}
className="text-muted-foreground hover:text-hover-neutral"
aria-label="Pin creature"
>
<Pin className="h-4 w-4" />
</button>
)}
{panelRole === "pinned" && (
<button
type="button"
onClick={onUnpin}
className="text-muted-foreground hover:text-hover-neutral"
aria-label="Unpin creature"
>
<PinOff className="h-4 w-4" />
</button>
)}
</div>
</div>
);
}
function DesktopPanel({
isFolded,
side,
creatureName,
panelRole,
showPinButton,
onToggleFold,
onPin,
onUnpin,
children,
}: {
isFolded: boolean;
side: "left" | "right";
creatureName: string;
panelRole: "browse" | "pinned";
showPinButton: boolean;
onToggleFold: () => void;
onPin: () => void;
onUnpin: () => void;
children: ReactNode;
}) {
const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l";
const foldedTranslate =
side === "right"
? "translate-x-[calc(100%-40px)]"
: "translate-x-[calc(-100%+40px)]";
return (
<div
className={`fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel ${sideClasses} ${isFolded ? foldedTranslate : "translate-x-0"}`}
>
{isFolded ? (
<FoldedTab
creatureName={creatureName}
side={side}
onToggleFold={onToggleFold}
/>
) : (
<>
<PanelHeader
panelRole={panelRole}
showPinButton={showPinButton}
onToggleFold={onToggleFold}
onPin={onPin}
onUnpin={onUnpin}
/>
<div className="flex-1 overflow-y-auto p-4">{children}</div>
</>
)}
</div>
);
}
function MobileDrawer({
onDismiss,
children,
}: {
onDismiss: () => void;
children: ReactNode;
}) {
const { offsetX, isSwiping, handlers } = useSwipeToDismiss(onDismiss);
return (
<div className="fixed inset-0 z-50">
<button
type="button"
className="absolute inset-0 bg-black/50 animate-in fade-in"
onClick={onDismiss}
aria-label="Close stat block"
/>
<div
className={`absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-l border-border bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`}
style={
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
}
{...handlers}
>
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<button
type="button"
onClick={onDismiss}
className="text-muted-foreground hover:text-hover-neutral"
aria-label="Fold stat block panel"
>
<PanelRightClose className="h-4 w-4" />
</button>
</div>
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
{children}
</div>
</div>
</div>
);
}
export function StatBlockPanel({
creatureId,
creature,
isSourceCached,
fetchAndCacheSource,
uploadAndCacheSource,
refreshCache,
panelRole,
isFolded,
onToggleFold,
onPin,
onUnpin,
showPinButton,
side,
onDismiss,
bulkImportMode,
bulkImportState,
onStartBulkImport,
onBulkImportDone,
}: StatBlockPanelProps) {
const [isDesktop, setIsDesktop] = useState( const [isDesktop, setIsDesktop] = useState(
() => window.matchMedia("(min-width: 1024px)").matches, () => window.matchMedia("(min-width: 1024px)").matches,
); );
const [needsFetch, setNeedsFetch] = useState(false);
const [checkingCache, setCheckingCache] = useState(false);
useEffect(() => { useEffect(() => {
const mq = window.matchMedia("(min-width: 1024px)"); const mq = window.matchMedia("(min-width: 1024px)");
@@ -20,58 +245,100 @@ export function StatBlockPanel({ creature, onClose }: StatBlockPanelProps) {
return () => mq.removeEventListener("change", handler); return () => mq.removeEventListener("change", handler);
}, []); }, []);
if (!creature) return null; useEffect(() => {
if (!creatureId || creature) {
setNeedsFetch(false);
return;
}
if (isDesktop) { const sourceCode = extractSourceCode(creatureId);
if (!sourceCode) {
setNeedsFetch(false);
return;
}
setCheckingCache(true);
isSourceCached(sourceCode).then((cached) => {
setNeedsFetch(!cached);
setCheckingCache(false);
});
}, [creatureId, creature, isSourceCached]);
if (!creatureId && !bulkImportMode) return null;
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
const handleSourceLoaded = async () => {
await refreshCache();
setNeedsFetch(false);
};
const renderContent = () => {
if (
bulkImportMode &&
bulkImportState &&
onStartBulkImport &&
onBulkImportDone
) {
return ( return (
<div className="fixed top-0 right-0 bottom-0 flex w-[400px] flex-col border-l border-border bg-card"> <BulkImportPrompt
<div className="flex items-center justify-between border-b border-border px-4 py-2"> importState={bulkImportState}
<span className="text-sm font-semibold text-muted-foreground"> onStartImport={onStartBulkImport}
Stat Block onDone={onBulkImportDone}
</span> />
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-hover-neutral"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<StatBlock creature={creature} />
</div>
</div>
); );
} }
// Mobile drawer if (checkingCache) {
return ( return (
<div className="fixed inset-0 z-50"> <div className="p-4 text-sm text-muted-foreground">Loading...</div>
{/* Backdrop */} );
<button }
type="button"
className="absolute inset-0 bg-black/50 animate-in fade-in" if (creature) {
onClick={onClose} return <StatBlock creature={creature} />;
aria-label="Close stat block" }
if (needsFetch && sourceCode) {
return (
<SourceFetchPrompt
sourceCode={sourceCode}
sourceDisplayName={getSourceDisplayName(sourceCode)}
fetchAndCacheSource={fetchAndCacheSource}
onSourceLoaded={handleSourceLoaded}
onUploadSource={uploadAndCacheSource}
/> />
{/* Drawer */} );
<div className="absolute top-0 right-0 bottom-0 w-[85%] max-w-md animate-slide-in-right border-l border-border bg-card shadow-xl"> }
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<span className="text-sm font-semibold text-muted-foreground"> return (
Stat Block <div className="p-4 text-sm text-muted-foreground">
</span> No stat block available
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-hover-neutral"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
<StatBlock creature={creature} />
</div>
</div>
</div> </div>
); );
};
const creatureName =
creature?.name ?? (bulkImportMode ? "Bulk Import" : "Creature");
if (isDesktop) {
return (
<DesktopPanel
isFolded={isFolded}
side={side}
creatureName={creatureName}
panelRole={panelRole}
showPinButton={showPinButton}
onToggleFold={onToggleFold}
onPin={onPin}
onUnpin={onUnpin}
>
{renderContent()}
</DesktopPanel>
);
}
if (panelRole === "pinned") return null;
return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
} }

View File

@@ -0,0 +1,47 @@
import { X } from "lucide-react";
import { useEffect } from "react";
import { createPortal } from "react-dom";
interface ToastProps {
message: string;
progress?: number;
onDismiss: () => void;
autoDismissMs?: number;
}
export function Toast({
message,
progress,
onDismiss,
autoDismissMs,
}: ToastProps) {
useEffect(() => {
if (autoDismissMs === undefined) return;
const timer = setTimeout(onDismiss, autoDismissMs);
return () => clearTimeout(timer);
}, [autoDismissMs, onDismiss]);
return createPortal(
<div className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2">
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg">
<span className="text-sm text-foreground">{message}</span>
{progress !== undefined && (
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${Math.round(progress * 100)}%` }}
/>
</div>
)}
<button
type="button"
onClick={onDismiss}
className="text-muted-foreground hover:text-hover-neutral"
>
<X className="h-3 w-3" />
</button>
</div>
</div>,
document.body,
);
}

View File

@@ -1,7 +1,8 @@
import type { Encounter } from "@initiative/domain"; import type { Encounter } from "@initiative/domain";
import { StepBack, StepForward, Trash2 } from "lucide-react"; import { Settings, StepBack, StepForward, Trash2 } from "lucide-react";
import { D20Icon } from "./d20-icon"; import { D20Icon } from "./d20-icon";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { ConfirmButton } from "./ui/confirm-button";
interface TurnNavigationProps { interface TurnNavigationProps {
encounter: Encounter; encounter: Encounter;
@@ -9,6 +10,7 @@ interface TurnNavigationProps {
onRetreatTurn: () => void; onRetreatTurn: () => void;
onClearEncounter: () => void; onClearEncounter: () => void;
onRollAllInitiative: () => void; onRollAllInitiative: () => void;
onOpenSourceManager: () => void;
} }
export function TurnNavigation({ export function TurnNavigation({
@@ -17,13 +19,15 @@ export function TurnNavigation({
onRetreatTurn, onRetreatTurn,
onClearEncounter, onClearEncounter,
onRollAllInitiative, onRollAllInitiative,
onOpenSourceManager,
}: TurnNavigationProps) { }: TurnNavigationProps) {
const hasCombatants = encounter.combatants.length > 0; const hasCombatants = encounter.combatants.length > 0;
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0; const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
const activeCombatant = encounter.combatants[encounter.activeIndex]; const activeCombatant = encounter.combatants[encounter.activeIndex];
return ( return (
<div className="flex items-center justify-between rounded-md border border-border bg-card px-4 py-3"> <div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
<div className="flex flex-shrink-0 items-center gap-3">
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
@@ -35,22 +39,22 @@ export function TurnNavigation({
> >
<StepBack className="h-5 w-5" /> <StepBack className="h-5 w-5" />
</Button> </Button>
<span className="rounded-full bg-muted text-foreground text-sm px-2 py-0.5 font-semibold">
<div className="text-center text-sm"> R{encounter.roundNumber}
{activeCombatant ? ( </span>
<> </div>
<span className="font-medium">Round {encounter.roundNumber}</span>
<span className="text-muted-foreground"> <div className="min-w-0 flex-1 text-center text-sm">
{" "} {activeCombatant ? (
{activeCombatant.name} <span className="truncate block font-medium">
{activeCombatant.name}
</span> </span>
</>
) : ( ) : (
<span className="text-muted-foreground">No combatants</span> <span className="text-muted-foreground">No combatants</span>
)} )}
</div> </div>
<div className="flex items-center gap-3"> <div className="flex flex-shrink-0 items-center gap-3">
<div className="flex items-center gap-0"> <div className="flex items-center gap-0">
<Button <Button
variant="ghost" variant="ghost"
@@ -65,12 +69,20 @@ export function TurnNavigation({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-muted-foreground hover:text-hover-destructive" className="h-8 w-8 text-muted-foreground hover:text-hover-neutral"
onClick={onClearEncounter} onClick={onOpenSourceManager}
disabled={!hasCombatants} title="Manage cached sources"
aria-label="Manage cached sources"
> >
<Trash2 className="h-5 w-5" /> <Settings className="h-5 w-5" />
</Button> </Button>
<ConfirmButton
icon={<Trash2 className="h-5 w-5" />}
label="Clear encounter"
onConfirm={onClearEncounter}
disabled={!hasCombatants}
className="h-8 w-8 text-muted-foreground"
/>
</div> </div>
<Button <Button
variant="outline" variant="outline"

View File

@@ -0,0 +1,114 @@
import { Check } from "lucide-react";
import {
type ReactElement,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { cn } from "../../lib/utils";
import { Button } from "./button";
interface ConfirmButtonProps {
readonly onConfirm: () => void;
readonly icon: ReactElement;
readonly label: string;
readonly className?: string;
readonly disabled?: boolean;
}
const REVERT_TIMEOUT_MS = 5_000;
export function ConfirmButton({
onConfirm,
icon,
label,
className,
disabled,
}: ConfirmButtonProps) {
const [isConfirming, setIsConfirming] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const wrapperRef = useRef<HTMLDivElement>(null);
const revert = useCallback(() => {
setIsConfirming(false);
clearTimeout(timerRef.current);
}, []);
// Cleanup timer on unmount
useEffect(() => {
return () => clearTimeout(timerRef.current);
}, []);
// Click-outside listener when confirming
useEffect(() => {
if (!isConfirming) return;
function handleMouseDown(e: MouseEvent) {
if (
wrapperRef.current &&
!wrapperRef.current.contains(e.target as Node)
) {
revert();
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
revert();
}
}
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isConfirming, revert]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
}
}, []);
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (disabled) return;
if (isConfirming) {
revert();
onConfirm();
} else {
setIsConfirming(true);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(revert, REVERT_TIMEOUT_MS);
}
},
[isConfirming, disabled, onConfirm, revert],
);
return (
<div ref={wrapperRef} className="inline-flex">
<Button
variant="ghost"
size="icon"
className={cn(
className,
isConfirming &&
"bg-destructive text-primary-foreground rounded-md animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground",
)}
onClick={handleClick}
onKeyDown={handleKeyDown}
onBlur={revert}
disabled={disabled}
aria-label={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
title={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
>
{isConfirming ? <Check size={16} /> : icon}
</Button>
</div>
);
}

View File

@@ -1,62 +1,126 @@
import type { Creature, CreatureId } from "@initiative/domain"; import type {
import { useEffect, useMemo, useRef, useState } from "react"; BestiaryIndexEntry,
import { normalizeBestiary } from "../adapters/bestiary-adapter.js"; Creature,
CreatureId,
} from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react";
import {
normalizeBestiary,
setSourceDisplayNames,
} from "../adapters/bestiary-adapter.js";
import * as bestiaryCache from "../adapters/bestiary-cache.js";
import {
getSourceDisplayName,
loadBestiaryIndex,
} from "../adapters/bestiary-index-adapter.js";
export interface SearchResult extends BestiaryIndexEntry {
readonly sourceDisplayName: string;
}
interface BestiaryHook { interface BestiaryHook {
search: (query: string) => Creature[]; search: (query: string) => SearchResult[];
getCreature: (id: CreatureId) => Creature | undefined; getCreature: (id: CreatureId) => Creature | undefined;
allCreatures: Creature[];
isLoaded: boolean; isLoaded: boolean;
isSourceCached: (sourceCode: string) => Promise<boolean>;
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
uploadAndCacheSource: (
sourceCode: string,
jsonData: unknown,
) => Promise<void>;
refreshCache: () => Promise<void>;
} }
export function useBestiary(): BestiaryHook { export function useBestiary(): BestiaryHook {
const [creatures, setCreatures] = useState<Creature[]>([]);
const [isLoaded, setIsLoaded] = useState(false); const [isLoaded, setIsLoaded] = useState(false);
const creatureMapRef = useRef<Map<string, Creature>>(new Map()); const creatureMapRef = useRef<Map<CreatureId, Creature>>(new Map());
const loadAttempted = useRef(false); const [, setTick] = useState(0);
useEffect(() => { useEffect(() => {
if (loadAttempted.current) return; const index = loadBestiaryIndex();
loadAttempted.current = true; setSourceDisplayNames(index.sources as Record<string, string>);
if (index.creatures.length > 0) {
import("../../../../data/bestiary/xmm.json")
// biome-ignore lint/suspicious/noExplicitAny: raw JSON shape varies per entry
.then((mod: any) => {
const raw = mod.default ?? mod;
try {
const normalized = normalizeBestiary(raw);
const map = new Map<string, Creature>();
for (const c of normalized) {
map.set(c.id, c);
}
creatureMapRef.current = map;
setCreatures(normalized);
setIsLoaded(true); setIsLoaded(true);
} catch {
// Normalization failed — bestiary unavailable
} }
})
.catch(() => { bestiaryCache.loadAllCachedCreatures().then((map) => {
// Import failed — bestiary unavailable creatureMapRef.current = map;
setTick((t) => t + 1);
}); });
}, []); }, []);
const search = useMemo(() => { const search = useCallback((query: string): SearchResult[] => {
return (query: string): Creature[] => {
if (query.length < 2) return []; if (query.length < 2) return [];
const lower = query.toLowerCase(); const lower = query.toLowerCase();
return creatures const index = loadBestiaryIndex();
return index.creatures
.filter((c) => c.name.toLowerCase().includes(lower)) .filter((c) => c.name.toLowerCase().includes(lower))
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => a.name.localeCompare(b.name))
.slice(0, 10); .slice(0, 10)
}; .map((c) => ({
}, [creatures]); ...c,
sourceDisplayName: getSourceDisplayName(c.source),
const getCreature = useMemo(() => { }));
return (id: CreatureId): Creature | undefined => {
return creatureMapRef.current.get(id);
};
}, []); }, []);
return { search, getCreature, allCreatures: creatures, isLoaded }; const getCreature = useCallback((id: CreatureId): Creature | undefined => {
return creatureMapRef.current.get(id);
}, []);
const isSourceCachedFn = useCallback(
(sourceCode: string): Promise<boolean> => {
return bestiaryCache.isSourceCached(sourceCode);
},
[],
);
const fetchAndCacheSource = useCallback(
async (sourceCode: string, url: string): Promise<void> => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to fetch: ${response.status} ${response.statusText}`,
);
}
const json = await response.json();
const creatures = normalizeBestiary(json);
const displayName = getSourceDisplayName(sourceCode);
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
for (const c of creatures) {
creatureMapRef.current.set(c.id, c);
}
setTick((t) => t + 1);
},
[],
);
const uploadAndCacheSource = useCallback(
async (sourceCode: string, jsonData: unknown): Promise<void> => {
// biome-ignore lint/suspicious/noExplicitAny: raw JSON shape varies
const creatures = normalizeBestiary(jsonData as any);
const displayName = getSourceDisplayName(sourceCode);
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
for (const c of creatures) {
creatureMapRef.current.set(c.id, c);
}
setTick((t) => t + 1);
},
[],
);
const refreshCache = useCallback(async (): Promise<void> => {
const map = await bestiaryCache.loadAllCachedCreatures();
creatureMapRef.current = map;
setTick((t) => t + 1);
}, []);
return {
search,
getCreature,
isLoaded,
isSourceCached: isSourceCachedFn,
fetchAndCacheSource,
uploadAndCacheSource,
refreshCache,
};
} }

View File

@@ -0,0 +1,120 @@
import { useCallback, useRef, useState } from "react";
import {
getAllSourceCodes,
getDefaultFetchUrl,
} from "../adapters/bestiary-index-adapter.js";
const BATCH_SIZE = 6;
export interface BulkImportState {
readonly status: "idle" | "loading" | "complete" | "partial-failure";
readonly total: number;
readonly completed: number;
readonly failed: number;
}
const IDLE_STATE: BulkImportState = {
status: "idle",
total: 0,
completed: 0,
failed: 0,
};
interface BulkImportHook {
state: BulkImportState;
startImport: (
baseUrl: string,
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
isSourceCached: (sourceCode: string) => Promise<boolean>,
refreshCache: () => Promise<void>,
) => void;
reset: () => void;
}
export function useBulkImport(): BulkImportHook {
const [state, setState] = useState<BulkImportState>(IDLE_STATE);
const countersRef = useRef({ completed: 0, failed: 0 });
const startImport = useCallback(
(
baseUrl: string,
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
isSourceCached: (sourceCode: string) => Promise<boolean>,
refreshCache: () => Promise<void>,
) => {
const allCodes = getAllSourceCodes();
const total = allCodes.length;
countersRef.current = { completed: 0, failed: 0 };
setState({ status: "loading", total, completed: 0, failed: 0 });
(async () => {
const cacheChecks = await Promise.all(
allCodes.map(async (code) => ({
code,
cached: await isSourceCached(code),
})),
);
const alreadyCached = cacheChecks.filter((c) => c.cached).length;
const uncached = cacheChecks.filter((c) => !c.cached);
countersRef.current.completed = alreadyCached;
if (uncached.length === 0) {
setState({
status: "complete",
total,
completed: total,
failed: 0,
});
return;
}
setState((s) => ({ ...s, completed: alreadyCached }));
for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
const batch = uncached.slice(i, i + BATCH_SIZE);
await Promise.allSettled(
batch.map(async ({ code }) => {
const url = getDefaultFetchUrl(code, baseUrl);
try {
await fetchAndCacheSource(code, url);
countersRef.current.completed++;
} catch (err) {
countersRef.current.failed++;
console.warn(
`[bulk-import] FAILED ${code} (${url}):`,
err instanceof Error ? err.message : err,
);
}
setState({
status: "loading",
total,
completed: countersRef.current.completed,
failed: countersRef.current.failed,
});
}),
);
}
await refreshCache();
const { completed, failed } = countersRef.current;
setState({
status: failed > 0 ? "partial-failure" : "complete",
total,
completed,
failed,
});
})();
},
[],
);
const reset = useCallback(() => {
setState(IDLE_STATE);
}, []);
return { state, startImport, reset };
}

View File

@@ -14,9 +14,9 @@ import {
toggleConditionUseCase, toggleConditionUseCase,
} from "@initiative/application"; } from "@initiative/application";
import type { import type {
BestiaryIndexEntry,
CombatantId, CombatantId,
ConditionId, ConditionId,
Creature,
DomainEvent, DomainEvent,
Encounter, Encounter,
} from "@initiative/domain"; } from "@initiative/domain";
@@ -24,6 +24,7 @@ import {
combatantId, combatantId,
createEncounter, createEncounter,
isDomainError, isDomainError,
creatureId as makeCreatureId,
resolveCreatureName, resolveCreatureName,
} from "@initiative/domain"; } from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
@@ -64,6 +65,33 @@ function deriveNextId(encounter: Encounter): number {
return max; return max;
} }
interface CombatantOpts {
initiative?: number;
ac?: number;
maxHp?: number;
}
function applyCombatantOpts(
makeStore: () => EncounterStore,
id: ReturnType<typeof combatantId>,
opts: CombatantOpts,
): DomainEvent[] {
const events: DomainEvent[] = [];
if (opts.maxHp !== undefined) {
const r = setHpUseCase(makeStore(), id, opts.maxHp);
if (!isDomainError(r)) events.push(...r);
}
if (opts.ac !== undefined) {
const r = setAcUseCase(makeStore(), id, opts.ac);
if (!isDomainError(r)) events.push(...r);
}
if (opts.initiative !== undefined) {
const r = setInitiativeUseCase(makeStore(), id, opts.initiative);
if (!isDomainError(r)) events.push(...r);
}
return events;
}
export function useEncounter() { export function useEncounter() {
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter); const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
const [events, setEvents] = useState<DomainEvent[]>([]); const [events, setEvents] = useState<DomainEvent[]>([]);
@@ -107,7 +135,7 @@ export function useEncounter() {
const nextId = useRef(deriveNextId(encounter)); const nextId = useRef(deriveNextId(encounter));
const addCombatant = useCallback( const addCombatant = useCallback(
(name: string) => { (name: string, opts?: CombatantOpts) => {
const id = combatantId(`c-${++nextId.current}`); const id = combatantId(`c-${++nextId.current}`);
const result = addCombatantUseCase(makeStore(), id, name); const result = addCombatantUseCase(makeStore(), id, name);
@@ -115,6 +143,13 @@ export function useEncounter() {
return; return;
} }
if (opts) {
const optEvents = applyCombatantOpts(makeStore, id, opts);
if (optEvents.length > 0) {
setEvents((prev) => [...prev, ...optEvents]);
}
}
setEvents((prev) => [...prev, ...result]); setEvents((prev) => [...prev, ...result]);
}, },
[makeStore], [makeStore],
@@ -225,10 +260,6 @@ export function useEncounter() {
); );
const clearEncounter = useCallback(() => { const clearEncounter = useCallback(() => {
if (!window.confirm("Clear the entire encounter? This cannot be undone.")) {
return;
}
const result = clearEncounterUseCase(makeStore()); const result = clearEncounterUseCase(makeStore());
if (isDomainError(result)) { if (isDomainError(result)) {
@@ -240,11 +271,11 @@ export function useEncounter() {
}, [makeStore]); }, [makeStore]);
const addFromBestiary = useCallback( const addFromBestiary = useCallback(
(creature: Creature) => { (entry: BestiaryIndexEntry) => {
const store = makeStore(); const store = makeStore();
const existingNames = store.get().combatants.map((c) => c.name); const existingNames = store.get().combatants.map((c) => c.name);
const { newName, renames } = resolveCreatureName( const { newName, renames } = resolveCreatureName(
creature.name, entry.name,
existingNames, existingNames,
); );
@@ -262,28 +293,34 @@ export function useEncounter() {
if (isDomainError(addResult)) return; if (isDomainError(addResult)) return;
// Set HP // Set HP
const hpResult = setHpUseCase(makeStore(), id, creature.hp.average); const hpResult = setHpUseCase(makeStore(), id, entry.hp);
if (!isDomainError(hpResult)) { if (!isDomainError(hpResult)) {
setEvents((prev) => [...prev, ...hpResult]); setEvents((prev) => [...prev, ...hpResult]);
} }
// Set AC // Set AC
if (creature.ac > 0) { if (entry.ac > 0) {
const acResult = setAcUseCase(makeStore(), id, creature.ac); const acResult = setAcUseCase(makeStore(), id, entry.ac);
if (!isDomainError(acResult)) { if (!isDomainError(acResult)) {
setEvents((prev) => [...prev, ...acResult]); setEvents((prev) => [...prev, ...acResult]);
} }
} }
// Set creatureId on the combatant // Derive creatureId from source + name
const slug = entry.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
// Set creatureId on the combatant (use store.save to keep ref in sync for batch calls)
const currentEncounter = store.get(); const currentEncounter = store.get();
const updated = { store.save({
...currentEncounter, ...currentEncounter,
combatants: currentEncounter.combatants.map((c) => combatants: currentEncounter.combatants.map((c) =>
c.id === id ? { ...c, creatureId: creature.id } : c, c.id === id ? { ...c, creatureId: cId } : c,
), ),
}; });
setEncounter(updated);
setEvents((prev) => [...prev, ...addResult]); setEvents((prev) => [...prev, ...addResult]);
}, },

View File

@@ -0,0 +1,72 @@
import { useCallback, useRef, useState } from "react";
const DISMISS_THRESHOLD = 0.35;
const VELOCITY_THRESHOLD = 0.5;
interface SwipeState {
offsetX: number;
isSwiping: boolean;
}
export function useSwipeToDismiss(onDismiss: () => void) {
const [swipe, setSwipe] = useState<SwipeState>({
offsetX: 0,
isSwiping: false,
});
const startX = useRef(0);
const startY = useRef(0);
const startTime = useRef(0);
const panelWidth = useRef(0);
const directionLocked = useRef<"horizontal" | "vertical" | null>(null);
const onTouchStart = useCallback((e: React.TouchEvent) => {
const touch = e.touches[0];
startX.current = touch.clientX;
startY.current = touch.clientY;
startTime.current = Date.now();
directionLocked.current = null;
const el = e.currentTarget as HTMLElement;
panelWidth.current = el.getBoundingClientRect().width;
}, []);
const onTouchMove = useCallback((e: React.TouchEvent) => {
const touch = e.touches[0];
const dx = touch.clientX - startX.current;
const dy = touch.clientY - startY.current;
if (!directionLocked.current) {
if (Math.abs(dx) < 10 && Math.abs(dy) < 10) return;
directionLocked.current =
Math.abs(dx) > Math.abs(dy) ? "horizontal" : "vertical";
}
if (directionLocked.current === "vertical") return;
const clampedX = Math.max(0, dx);
setSwipe({ offsetX: clampedX, isSwiping: true });
}, []);
const onTouchEnd = useCallback(() => {
if (directionLocked.current !== "horizontal") {
setSwipe({ offsetX: 0, isSwiping: false });
return;
}
const elapsed = (Date.now() - startTime.current) / 1000;
const velocity = swipe.offsetX / elapsed / panelWidth.current;
const ratio =
panelWidth.current > 0 ? swipe.offsetX / panelWidth.current : 0;
if (ratio > DISMISS_THRESHOLD || velocity > VELOCITY_THRESHOLD) {
onDismiss();
}
setSwipe({ offsetX: 0, isSwiping: false });
}, [swipe.offsetX, onDismiss]);
return {
offsetX: swipe.offsetX,
isSwiping: swipe.isSwiping,
handlers: { onTouchStart, onTouchMove, onTouchEnd },
};
}

View File

@@ -68,6 +68,32 @@
animation: slide-in-right 200ms ease-out; animation: slide-in-right 200ms ease-out;
} }
@keyframes confirm-pulse {
0% {
scale: 1;
}
50% {
scale: 1.15;
}
100% {
scale: 1;
}
}
@custom-variant pointer-coarse (@media (pointer: coarse));
@utility animate-confirm-pulse {
animation: confirm-pulse 300ms ease-out;
}
@utility transition-slide-panel {
transition: translate 200ms ease-out;
}
@utility writing-vertical-rl {
writing-mode: vertical-rl;
}
@utility animate-concentration-pulse { @utility animate-concentration-pulse {
animation: animation:
concentration-shake 450ms ease-out, concentration-shake 450ms ease-out,

View File

@@ -18,6 +18,76 @@ export function saveEncounter(encounter: Encounter): void {
} }
} }
function validateAc(value: unknown): number | undefined {
return typeof value === "number" && Number.isInteger(value) && value >= 0
? value
: undefined;
}
function validateConditions(value: unknown): ConditionId[] | undefined {
if (!Array.isArray(value)) return undefined;
const valid = value.filter(
(v): v is ConditionId =>
typeof v === "string" && VALID_CONDITION_IDS.has(v),
);
return valid.length > 0 ? valid : undefined;
}
function validateCreatureId(value: unknown) {
return typeof value === "string" && value.length > 0
? creatureId(value)
: undefined;
}
function validateHp(
rawMaxHp: unknown,
rawCurrentHp: unknown,
): { maxHp: number; currentHp: number } | undefined {
if (
typeof rawMaxHp !== "number" ||
!Number.isInteger(rawMaxHp) ||
rawMaxHp < 1
) {
return undefined;
}
const validCurrentHp =
typeof rawCurrentHp === "number" &&
Number.isInteger(rawCurrentHp) &&
rawCurrentHp >= 0 &&
rawCurrentHp <= rawMaxHp;
return {
maxHp: rawMaxHp,
currentHp: validCurrentHp ? rawCurrentHp : rawMaxHp,
};
}
function rehydrateCombatant(c: unknown) {
const entry = c as Record<string, unknown>;
const base = {
id: combatantId(entry.id as string),
name: entry.name as string,
initiative:
typeof entry.initiative === "number" ? entry.initiative : undefined,
};
const shared = {
...base,
ac: validateAc(entry.ac),
conditions: validateConditions(entry.conditions),
isConcentrating: entry.isConcentrating === true ? true : undefined,
creatureId: validateCreatureId(entry.creatureId),
};
const hp = validateHp(entry.maxHp, entry.currentHp);
return hp ? { ...shared, ...hp } : shared;
}
function isValidCombatantEntry(c: unknown): boolean {
if (typeof c !== "object" || c === null || Array.isArray(c)) return false;
const entry = c as Record<string, unknown>;
return typeof entry.id === "string" && typeof entry.name === "string";
}
export function loadEncounter(): Encounter | null { export function loadEncounter(): Encounter | null {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
@@ -45,82 +115,9 @@ export function loadEncounter(): Encounter | null {
}; };
} }
for (const c of combatants) { if (!combatants.every(isValidCombatantEntry)) return null;
if (typeof c !== "object" || c === null || Array.isArray(c)) return null;
const entry = c as Record<string, unknown>;
if (typeof entry.id !== "string") return null;
if (typeof entry.name !== "string") return null;
}
const rehydrated = combatants.map((c) => { const rehydrated = combatants.map(rehydrateCombatant);
const entry = c as Record<string, unknown>;
const base = {
id: combatantId(entry.id as string),
name: entry.name as string,
initiative:
typeof entry.initiative === "number" ? entry.initiative : undefined,
};
// Validate AC field
const ac = entry.ac;
const validAc =
typeof ac === "number" && Number.isInteger(ac) && ac >= 0
? ac
: undefined;
// Validate conditions field
const rawConditions = entry.conditions;
const validConditions: ConditionId[] | undefined = Array.isArray(
rawConditions,
)
? (rawConditions.filter(
(v): v is ConditionId =>
typeof v === "string" && VALID_CONDITION_IDS.has(v),
) as ConditionId[])
: undefined;
const conditions =
validConditions && validConditions.length > 0
? validConditions
: undefined;
// Validate isConcentrating field
const isConcentrating = entry.isConcentrating === true ? true : undefined;
// Validate creatureId field
const rawCreatureId = entry.creatureId;
const validCreatureId =
typeof rawCreatureId === "string" && rawCreatureId.length > 0
? creatureId(rawCreatureId)
: undefined;
// Validate and attach HP fields if valid
const maxHp = entry.maxHp;
const currentHp = entry.currentHp;
if (typeof maxHp === "number" && Number.isInteger(maxHp) && maxHp >= 1) {
const validCurrentHp =
typeof currentHp === "number" &&
Number.isInteger(currentHp) &&
currentHp >= 0 &&
currentHp <= maxHp;
return {
...base,
ac: validAc,
conditions,
isConcentrating,
creatureId: validCreatureId,
maxHp,
currentHp: validCurrentHp ? currentHp : maxHp,
};
}
return {
...base,
ac: validAc,
conditions,
isConcentrating,
creatureId: validCreatureId,
};
});
const result = createEncounter( const result = createEncounter(
rehydrated, rehydrated,

View File

@@ -6,7 +6,9 @@
"!**/dist/**", "!**/dist/**",
"!.claude/**", "!.claude/**",
"!.specify/**", "!.specify/**",
"!specs/**" "!specs/**",
"!coverage/**",
"!.pnpm-store/**"
] ]
}, },
"assist": { "assist": {
@@ -27,7 +29,15 @@
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true "recommended": true,
"complexity": {
"noExcessiveCognitiveComplexity": {
"level": "error",
"options": {
"maxAllowedComplexity": 15
}
}
}
} }
} }
} }

36540
data/bestiary/index.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

4
docs/agents/.gitkeep Normal file
View File

@@ -0,0 +1,4 @@
# Agent Artifacts
Research reports and implementation plans generated by RPI skills.

View File

View File

9
nginx.conf Normal file
View File

@@ -0,0 +1,9 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -3,6 +3,7 @@
"packageManager": "pnpm@10.6.0", "packageManager": "pnpm@10.6.0",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.0.0", "@biomejs/biome": "2.0.0",
"@vitest/coverage-v8": "^3.2.4",
"jscpd": "^4.0.8", "jscpd": "^4.0.8",
"knip": "^5.85.0", "knip": "^5.85.0",
"lefthook": "^1.11.0", "lefthook": "^1.11.0",
@@ -20,6 +21,6 @@
"test:watch": "vitest", "test:watch": "vitest",
"knip": "knip", "knip": "knip",
"jscpd": "jscpd", "jscpd": "jscpd",
"check": "knip && biome check . && tsc --build && vitest run && jscpd" "check": "pnpm audit --audit-level=high && knip && biome check . && tsc --build && vitest run && jscpd"
} }
} }

View File

@@ -3,7 +3,7 @@ export { adjustHpUseCase } from "./adjust-hp-use-case.js";
export { advanceTurnUseCase } from "./advance-turn-use-case.js"; export { advanceTurnUseCase } from "./advance-turn-use-case.js";
export { clearEncounterUseCase } from "./clear-encounter-use-case.js"; export { clearEncounterUseCase } from "./clear-encounter-use-case.js";
export { editCombatantUseCase } from "./edit-combatant-use-case.js"; export { editCombatantUseCase } from "./edit-combatant-use-case.js";
export type { EncounterStore } from "./ports.js"; export type { BestiarySourceCache, EncounterStore } from "./ports.js";
export { removeCombatantUseCase } from "./remove-combatant-use-case.js"; export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
export { retreatTurnUseCase } from "./retreat-turn-use-case.js"; export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
export { rollAllInitiativeUseCase } from "./roll-all-initiative-use-case.js"; export { rollAllInitiativeUseCase } from "./roll-all-initiative-use-case.js";

View File

@@ -1,6 +1,11 @@
import type { Encounter } from "@initiative/domain"; import type { Creature, CreatureId, Encounter } from "@initiative/domain";
export interface EncounterStore { export interface EncounterStore {
get(): Encounter; get(): Encounter;
save(encounter: Encounter): void; save(encounter: Encounter): void;
} }
export interface BestiarySourceCache {
getCreature(creatureId: CreatureId): Creature | undefined;
isSourceCached(sourceCode: string): boolean;
}

View File

@@ -75,6 +75,23 @@ export interface Creature {
readonly spellcasting?: readonly SpellcastingBlock[]; readonly spellcasting?: readonly SpellcastingBlock[];
} }
export interface BestiaryIndexEntry {
readonly name: string;
readonly source: string;
readonly ac: number;
readonly hp: number;
readonly dex: number;
readonly cr: string;
readonly initiativeProficiency: number;
readonly size: string;
readonly type: string;
}
export interface BestiaryIndex {
readonly sources: Readonly<Record<string, string>>;
readonly creatures: readonly BestiaryIndexEntry[];
}
/** Maps a CR string to the corresponding proficiency bonus. */ /** Maps a CR string to the corresponding proficiency bonus. */
export function proficiencyBonus(cr: string): number { export function proficiencyBonus(cr: string): number {
const numericCr = cr.includes("/") const numericCr = cr.includes("/")

View File

@@ -13,6 +13,8 @@ export {
VALID_CONDITION_IDS, VALID_CONDITION_IDS,
} from "./conditions.js"; } from "./conditions.js";
export { export {
type BestiaryIndex,
type BestiaryIndexEntry,
type BestiarySource, type BestiarySource,
type Creature, type Creature,
type CreatureId, type CreatureId,

View File

@@ -62,8 +62,9 @@ export function setInitiative(
const bHas = b.c.initiative !== undefined; const bHas = b.c.initiative !== undefined;
if (aHas && bHas) { if (aHas && bHas) {
// biome-ignore lint: both checked above const aInit = a.c.initiative as number;
const diff = b.c.initiative! - a.c.initiative!; const bInit = b.c.initiative as number;
const diff = bInit - aInit;
return diff !== 0 ? diff : a.i - b.i; return diff !== 0 ? diff : a.i - b.i;
} }
if (aHas && !bHas) return -1; if (aHas && !bHas) return -1;

897
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -47,29 +47,25 @@ function matchesForbidden(importPath, forbidden) {
return importPath === forbidden || importPath.startsWith(`${forbidden}/`); return importPath === forbidden || importPath.startsWith(`${forbidden}/`);
} }
/** @returns {{ file: string, line: number, importPath: string, forbidden: string }[]} */ const IMPORT_RE =
export function checkLayerBoundaries() { /(?:import|from)\s+["']([^"']+)["']|require\s*\(\s*["']([^"']+)["']\s*\)/;
/**
* Check a single file for forbidden imports.
* @param {string} file
* @param {string[]} forbidden
* @returns {{ file: string, line: number, importPath: string, forbidden: string }[]}
*/
function checkFile(file, forbidden) {
/** @type {{ file: string, line: number, importPath: string, forbidden: string }[]} */ /** @type {{ file: string, line: number, importPath: string, forbidden: string }[]} */
const violations = []; const violations = [];
for (const [srcDir, forbidden] of Object.entries(FORBIDDEN)) {
const absDir = join(ROOT, srcDir);
let files;
try {
files = collectTsFiles(absDir);
} catch {
continue;
}
for (const file of files) {
const content = readFileSync(file, "utf-8"); const content = readFileSync(file, "utf-8");
const lines = content.split("\n"); const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const line = lines[i]; const match = lines[i].match(IMPORT_RE);
const match = line.match(
/(?:import|from)\s+["']([^"']+)["']|require\s*\(\s*["']([^"']+)["']\s*\)/,
);
if (!match) continue; if (!match) continue;
const importPath = match[1] || match[2]; const importPath = match[1] || match[2];
for (const f of forbidden) { for (const f of forbidden) {
if (matchesForbidden(importPath, f)) { if (matchesForbidden(importPath, f)) {
@@ -82,12 +78,34 @@ export function checkLayerBoundaries() {
} }
} }
} }
}
}
return violations; return violations;
} }
/**
* Check all files in a layer directory for forbidden imports.
* @param {string} srcDir
* @param {string[]} forbidden
* @returns {{ file: string, line: number, importPath: string, forbidden: string }[]}
*/
function checkLayer(srcDir, forbidden) {
const absDir = join(ROOT, srcDir);
let files;
try {
files = collectTsFiles(absDir);
} catch {
return [];
}
return files.flatMap((file) => checkFile(file, forbidden));
}
/** @returns {{ file: string, line: number, importPath: string, forbidden: string }[]} */
export function checkLayerBoundaries() {
return Object.entries(FORBIDDEN).flatMap(([srcDir, forbidden]) =>
checkLayer(srcDir, forbidden),
);
}
// Run as CLI if invoked directly // Run as CLI if invoked directly
if ( if (
process.argv[1] && process.argv[1] &&

View File

@@ -0,0 +1,166 @@
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
// Usage: node scripts/generate-bestiary-index.mjs <path-to-5etools-src>
//
// Requires a local clone/checkout of https://github.com/5etools-mirror-3/5etools-src
// with at least data/bestiary/, data/books.json, and data/adventures.json.
//
// Example:
// git clone --depth 1 --sparse https://github.com/5etools-mirror-3/5etools-src.git /tmp/5etools
// cd /tmp/5etools && git sparse-checkout set data/bestiary data
// node scripts/generate-bestiary-index.mjs /tmp/5etools
const TOOLS_ROOT = process.argv[2];
if (!TOOLS_ROOT) {
console.error(
"Usage: node scripts/generate-bestiary-index.mjs <5etools-src-path>",
);
process.exit(1);
}
const PROJECT_ROOT = join(import.meta.dirname, "..");
const BESTIARY_DIR = join(TOOLS_ROOT, "data/bestiary");
const BOOKS_PATH = join(TOOLS_ROOT, "data/books.json");
const ADVENTURES_PATH = join(TOOLS_ROOT, "data/adventures.json");
const OUTPUT_PATH = join(PROJECT_ROOT, "data/bestiary/index.json");
// --- Build source display name map from books.json + adventures.json ---
/** Populate map from a list of entries that have source/id + name. */
function addEntriesToMap(map, entries) {
for (const entry of entries) {
if (!entry.name) continue;
if (entry.source) {
map[entry.source] = entry.name;
}
// Some entries use "id" instead of "source"
if (entry.id && !map[entry.id]) {
map[entry.id] = entry.name;
}
}
}
function buildSourceMap() {
const map = {};
const books = JSON.parse(readFileSync(BOOKS_PATH, "utf-8"));
addEntriesToMap(map, books.book ?? []);
const adventures = JSON.parse(readFileSync(ADVENTURES_PATH, "utf-8"));
addEntriesToMap(map, adventures.adventure ?? []);
// Manual additions for sources missing from books.json / adventures.json
const manual = {
ESK: "Essentials Kit",
MCV1SC: "Monstrous Compendium Volume 1: Spelljammer Creatures",
MCV2DC: "Monstrous Compendium Volume 2: Dragonlance Creatures",
MCV3MC: "Monstrous Compendium Volume 3: Minecraft Creatures",
MCV4EC: "Monstrous Compendium Volume 4: Eldraine Creatures",
MFF: "Mordenkainen's Fiendish Folio",
MisMV1: "Misplaced Monsters: Volume 1",
SADS: "Sapphire Anniversary Dice Set",
TftYP: "Tales from the Yawning Portal",
VD: "Vecna Dossier",
};
for (const [k, v] of Object.entries(manual)) {
if (!map[k]) map[k] = v;
}
return map;
}
// --- Extract type string from raw type field ---
function extractType(type) {
if (typeof type === "string") return type;
if (typeof type?.type === "string") return type.type;
if (typeof type?.type === "object" && Array.isArray(type.type.choose)) {
return type.type.choose.join("/");
}
return "unknown";
}
// --- Extract AC from raw ac field ---
function extractAc(ac) {
if (!Array.isArray(ac) || ac.length === 0) return 0;
const first = ac[0];
if (typeof first === "number") return first;
if (typeof first === "object" && typeof first.ac === "number")
return first.ac;
return 0;
}
// --- Extract CR from raw cr field ---
function extractCr(cr) {
if (typeof cr === "string") return cr;
if (typeof cr === "object" && typeof cr.cr === "string") return cr.cr;
return "0";
}
// --- Main ---
const sourceMap = buildSourceMap();
const files = readdirSync(BESTIARY_DIR).filter(
(f) => f.startsWith("bestiary-") && f.endsWith(".json"),
);
const creatures = [];
const unmappedSources = new Set();
for (const file of files.sort()) {
const raw = JSON.parse(readFileSync(join(BESTIARY_DIR, file), "utf-8"));
const monsters = raw.monster ?? [];
for (const m of monsters) {
// Skip creatures that are copies/references (no actual stats)
if (m._copy || m.hp == null || m.ac == null) continue;
const source = m.source ?? "";
if (source && !sourceMap[source]) {
unmappedSources.add(source);
}
creatures.push({
n: m.name,
s: source,
ac: extractAc(m.ac),
hp: m.hp.average ?? 0,
dx: m.dex ?? 10,
cr: extractCr(m.cr),
ip: m.initiative?.proficiency ?? 0,
sz: Array.isArray(m.size) ? m.size[0] : (m.size ?? "M"),
tp: extractType(m.type),
});
}
}
// Sort by name then source for stable output
creatures.sort((a, b) => a.n.localeCompare(b.n) || a.s.localeCompare(b.s));
// Filter sourceMap to only include sources that appear in the index
const usedSources = new Set(creatures.map((c) => c.s));
const filteredSourceMap = {};
for (const [key, value] of Object.entries(sourceMap)) {
if (usedSources.has(key)) {
filteredSourceMap[key] = value;
}
}
const output = {
sources: filteredSourceMap,
creatures,
};
writeFileSync(OUTPUT_PATH, JSON.stringify(output));
// Stats
const rawSize = Buffer.byteLength(JSON.stringify(output));
console.log(`Sources: ${Object.keys(filteredSourceMap).length}`);
console.log(`Creatures: ${creatures.length}`);
console.log(`Output size: ${(rawSize / 1024).toFixed(1)} KB`);
if (unmappedSources.size > 0) {
console.log(`Unmapped sources: ${[...unmappedSources].sort().join(", ")}`);
}

View File

@@ -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 18 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.

View File

@@ -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).

View File

@@ -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 18, 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 (T001T006)
2. Complete Phase 2: Domain (T007T011)
3. **STOP and VALIDATE**: `pnpm check` passes, all 8 scenarios green
### Full Feature (Milestone 2)
4. Complete Phase 3: App + Web Shell (T012T016)
5. **VALIDATE**: `pnpm check` passes, app runs in browser
---
## Notes
- All task IDs (T001T016) 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)

View File

@@ -0,0 +1,404 @@
# 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.
---
**Story C3 — Rename trigger UX (Priority: P1)**
A user wants to rename a combatant. Single-clicking the name opens the stat block panel instead of entering edit mode. To rename, the user double-clicks the name or long-presses on touch devices. A `cursor-text` cursor on hover signals that the name is editable.
**Acceptance Scenarios**:
1. **Given** a combatant row is visible, **When** the user single-clicks the combatant name, **Then** the stat block panel opens or toggles — inline edit mode is NOT entered.
2. **Given** a combatant row is visible, **When** the user double-clicks the combatant name, **Then** inline edit mode is entered for that combatant's name.
3. **Given** a combatant row is visible on a pointer device, **When** the user hovers over the combatant name, **Then** the cursor changes to a text cursor (`cursor-text`) to signal editability.
4. **Given** a combatant row is visible on a touch device, **When** the user long-presses the combatant name, **Then** inline edit mode is entered for that combatant's name.
7. **Given** inline edit mode has been entered (via any trigger), **When** the user types a new name and presses Enter or blurs the field, **Then** the name is committed. **When** the user presses Escape, **Then** the edit is cancelled and the original name is restored.
---
### 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-007FR-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 for each combatant, activated by double-clicking the name or long-pressing on touch devices. The name MUST display a `cursor-text` cursor on hover to signal editability. Single-clicking the name MUST open/toggle the stat block panel, not enter edit mode. 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.
- **Name single-click vs double-click**: A single click on the combatant name opens the stat block panel; only a completed double-click enters inline edit mode. The system must disambiguate between the two gestures.
- **Touch edit affordance**: No hover-dependent affordance is shown on touch devices. Long-press is the touch equivalent for entering edit mode.
- **Long-press threshold**: The long-press duration should follow platform conventions (typically ~500ms). A short tap must not trigger edit mode.
---
## 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 is activated by double-click or long-press (touch). A `cursor-text` cursor on hover signals editability. Single-clicking the name opens the stat block panel.

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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.

View File

@@ -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.

View File

@@ -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. T002T003: Domain function + exports
3. T004 + T005 in parallel: Tests + use case
4. T006T008: Application exports → hook → UI
5. T009T010: 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)

View File

@@ -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.

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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 |

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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 (T001T002)
2. Complete Phase 2: Domain tests + function (T003T005)
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

301
specs/004-bestiary/spec.md Normal file
View File

@@ -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.

View File

@@ -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`.

View File

@@ -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 |

View File

@@ -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"` |

View File

@@ -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`.

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 (T001T003)
- **User Story 2 (Phase 3)**: Depends on Setup (T001T003); 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 (T006T010)
- 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 (T001T003)
2. Complete Phase 2: User Story 1 (T004T010)
3. **STOP and VALIDATE**: `pnpm check` passes, rename works in browser
4. Deploy/demo if ready
### Full Feature
1. Setup (T001T003) → Foundation ready
2. User Story 1 (T004T010) → Rename works end-to-end (MVP!)
3. User Story 2 (T011T012) → Error handling complete
4. Polish (T013T014) → 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

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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).

View File

@@ -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 24 (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

View File

@@ -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`.

Some files were not shown because too many files have changed in this diff Show More