40 Commits

Author SHA1 Message Date
Lukas
07cdd4867a Fix custom combatant form disappearing when dismissing suggestions
All checks were successful
CI / check (push) Successful in 46s
CI / build-image (push) Successful in 18s
The "Add as custom" button and Escape key were clearing the name input
along with the suggestions, preventing the custom fields (Init, AC,
MaxHP) from ever appearing. Now only the suggestions are dismissed,
keeping the typed name intact so the custom combatant form renders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:30:17 +01:00
Lukas
85acb5c185 Migrate icon buttons to Button component and simplify size variants
All checks were successful
CI / check (push) Successful in 48s
CI / build-image (push) Successful in 18s
Replace raw <button> elements with Button variant="ghost" in stat-block
panel, toast, player modals. Add icon-sm size variant (h-6 w-6) for
compact contexts. Consolidate text button sizes into a single default
(h-8 px-3), removing the redundant sm variant. Add size prop to
ConfirmButton for consistent sizing.

Button now has three sizes: default (text), icon, icon-sm.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:22:00 +01:00
Lukas
f9ef64bb00 Unify hover effects via semantic theme tokens
Replace one-off hover colors with hover-neutral/hover-destructive tokens
so all interactive elements respond consistently to theme changes. Fix
hover-neutral-bg token value (was identical to card surface, making hover
invisible on card backgrounds) to a semi-transparent primary tint. Switch
turn nav buttons to outline variant for visible hover feedback. Convert HP
popover damage/heal to plain buttons to avoid ghost variant hover conflict
with tailwind-merge.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:58:01 +01:00
Lukas
bd39808000 Declutter action bars: overflow menu, browse toggle, conditional D20
All checks were successful
CI / check (push) Successful in 48s
CI / build-image (push) Successful in 18s
Top bar stripped to turn navigation only (Prev, round badge, Clear, Next).
Roll All Initiative, Manage Sources, and Bulk Import moved to a new
overflow menu in the bottom bar. Player Characters also moved there.

Browse stat blocks is now an Eye/EyeOff toggle inside the search input
that switches between add mode and browse mode. Add button only appears
when entering a custom creature name. Roll All Initiative button shows
conditionally — only when bestiary creatures lack initiative values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:31:25 +01:00
Lukas
75778884bd Hide top bar in empty state and animate it in with first combatant
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 18s
The turn navigation bar is now hidden when no combatants exist, keeping
the empty state clean. It slides down from above when the first
combatant is added, synchronized with the action bar settling animation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:43:41 +01:00
Lukas
72d4f30e60 Center action bar in empty state for better onboarding UX
Replace the abstract + icon with the actual input field centered at the
optical center when no combatants exist. Animate the transition in both
directions: settling down when the first combatant is added, rising up
when all combatants are removed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:29:51 +01:00
Lukas
96b37d4bdd Color player character names instead of left border
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 18s
Player characters now show their chosen color on their name text
rather than as a left border glow. Left border is reserved for
active row (accent) and concentration (purple).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:52:09 +01:00
Lukas
76ca78c169 Improve player modals: Escape to close, trash icon for delete
Both player management and create/edit modals now close on Escape.
Delete player character button uses Trash2 icon instead of X to
distinguish permanent deletion from dismissal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:51:58 +01:00
Lukas
b0c27b8ab9 Add red hover effect to destructive buttons
ConfirmButton now shows hover:text-hover-destructive in its default
state. Source manager delete buttons and Clear All get matching
destructive hover styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:51:34 +01:00
Lukas
458c277e9f Polish UI: consistent icon buttons, tooltips, modal backdrop close, and top bar layout
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 17s
- Standardize icon button sizing (size="icon") and color (text-muted-foreground) across top and bottom bars
- Group bottom bar icon buttons with gap-0 to match top bar style
- Add missing tooltips/aria-labels for stat block viewer, bulk import buttons
- Replace Settings icon with Library for source manager
- Make step forward/back buttons use primary (solid) variant
- Move round badge next to combatant name in center of top bar
- Close player create/edit and management modals on backdrop click

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:47:47 +01:00
Lukas
91703ddebc Add player character management feature
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 18s
Persistent player character templates (name, AC, HP, color, icon) with
full CRUD, bestiary-style search to add PCs to encounters with pre-filled
stats, and color/icon visual distinction in combatant rows. Also stops
the stat block panel from auto-opening when adding a creature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:11:08 +01:00
Lukas
768e7a390f Improve empty encounter UX with interactive add button
All checks were successful
CI / check (push) Successful in 44s
CI / build-image (push) Successful in 22s
Replace the static "No combatants yet" text with a centered, breathing
"+" icon that focuses the action bar input on click, guiding users to
add their first combatant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:56:56 +01:00
Lukas
7feaf90eab Add "custom creature" option to bestiary suggestions dropdown
When typing a name that partially matches bestiary entries, users
couldn't access the custom creature fields (Init/AC/MaxHP). Now a
prominent option at the top of the dropdown lets users dismiss
suggestions and add a custom creature instead, with an Esc hint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:45:39 +01:00
Lukas
b39e4923e1 Remove demo combatants and allow empty encounters
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 28s
Empty encounters are now valid (INV-1 updated). New sessions start
with zero combatants instead of pre-populated Aria/Brak/Cael.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:24:26 +01:00
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
294 changed files with 48093 additions and 78847 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
- Skip if project is purely internal (build scripts, one-off tools, etc.)
3. **Agent context update**:
- Run `.specify/scripts/bash/update-agent-context.sh claude`
- These scripts detect which AI agent is in use
- Update the appropriate agent-specific context file
- Add only new technology from current plan
- Preserve manual additions between markers
**Output**: data-model.md, /contracts/*, quickstart.md, agent-specific file
**Output**: data-model.md, /contracts/*, quickstart.md
## Key rules

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
───────────────────
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:
- Development Workflow: added automated-checks merge gate
Templates requiring updates:
- .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
- Development Workflow: specs are living feature documents; full pipeline for new features only
Templates requiring updates: none
-->
# Encounter Console Constitution
@@ -29,7 +25,7 @@ be injected at the boundary, never sourced inside the domain layer.
### 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:
1. **Domain** — pure types, state transitions, validation rules.
@@ -39,34 +35,21 @@ dependency direction:
interfaces that Adapters implement. May import Domain only.
3. **Adapters** — I/O, persistence, UI rendering, external APIs.
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.
### III. Agent Boundary
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
### III. Clarification-First
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
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
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.
### V. Escalation Gates
### IV. Escalation Gates
Any feature, requirement, or scope change not present in the current
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`.
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
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;
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,
rule-system specifics, or encounter resolution logic. Such details
@@ -96,9 +79,9 @@ architecture, and quality — not product behavior.
## Scope Constraints
- The Encounter Console's primary focus is initiative tracking and
encounter state management. Adjacent capabilities (e.g., richer
game-engine features) are not in the MVP baseline but may be
added via spec updates in future iterations.
encounter state management. Adjacent capabilities (e.g., bestiary
integration, richer game-engine features) may be added via spec
updates.
- Technology choices, UI framework, and storage mechanism are
spec-level decisions, not constitutional mandates.
- 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
static analysis as defined by the project) pass.
- Every feature begins with a spec (`/speckit.specify`).
- Implementation follows the plan → tasks → implement pipeline.
- Specs describe **features**, not individual changes. Each spec is
a living document. New features begin with `/speckit.specify`
(which creates a feature branch for the full speckit pipeline);
changes to existing features update the existing spec via
`/integrate-issue`.
- The full pipeline (spec → plan → tasks → implement) applies to new
features and significant additions. Bug fixes, tooling changes,
and trivial UI adjustments do not require specs.
- Domain logic MUST be testable without mocks for external systems.
- Long-running or multi-step state transitions SHOULD be verifiable
through reproducible event logs or snapshot-style tests.
- Commits SHOULD be atomic and map to individual tasks where
practical.
- Layer boundary compliance MUST be verified by automated import
rules or architectural tests. Agent-assisted or manual review MAY
supplement but not replace automated checks.
rules or architectural tests.
- 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
@@ -142,4 +140,4 @@ MUST comply with its principles.
**Compliance review**: Every spec and plan MUST include a
Constitution Check section validating adherence to all principles.
**Version**: 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
```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 test # Run all tests (Vitest)
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.
- **Application** — Orchestrates domain calls via port interfaces (e.g., `EncounterStore`). No business logic here.
- **Web** — React adapter. Implements ports using hooks/state.
- **Application** — Orchestrates domain calls via port interfaces (`EncounterStore`, `BestiarySourceCache`). No business logic here.
- **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.
### 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
- **Biome 2.0** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
@@ -39,7 +68,40 @@ 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.
- **Domain events** are plain data objects with a `type` discriminant — no classes.
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks.
- **Feature specs** live in `specs/<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
- `specs/005-player-characters/` — persistent player character templates (CRUD), search & add to encounters, color/icon visual distinction, `PlayerCharacterStore` port
## Constitution (key principles)
@@ -49,38 +111,11 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
2. **Layered Architecture** — Domain → Application → Adapters. Never skip layers or reverse dependencies.
3. **Clarification-First** — Ask before making non-trivial assumptions.
4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans.
5. **Every feature begins with a spec** — Spec → Plan → Tasks → Implementation.
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)
- TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Vite 6, Tailwind CSS v4, Lucide Reac (005-player-characters)
- localStorage (new key `"initiative:player-characters"`) (005-player-characters)
## Recent Changes
- 003-remove-combatant: Added TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite
- 005-player-characters: Added TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Vite 6, Tailwind CSS v4, Lucide Reac

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,18 @@
# 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
- **Player characters** — create reusable player character templates with name, AC, HP, color, and icon; search and add them to encounters with pre-filled stats; manage (edit/delete) from a dedicated panel
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB; player characters stored independently
## Prerequisites
- Node.js 22
- Node.js 22+
- pnpm 10.6+
## Getting Started
@@ -14,9 +22,7 @@ pnpm install
pnpm --filter web dev
```
Open the URL printed in your terminal (typically `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.
Open `http://localhost:5173`.
## Scripts
@@ -24,5 +30,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 build` | Production build |
| `pnpm test` | Run all tests |
| `pnpm check` | Full merge gate (format, lint, typecheck, test) |
| `pnpm test` | Run all tests (Vitest) |
| `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:*",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"idb": "^8.0.3",
"lucide-react": "^0.577.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -20,9 +21,12 @@
},
"devDependencies": {
"@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-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"jsdom": "^28.1.0",
"tailwindcss": "^4.2.1",
"vite": "^6.2.0"
}

View File

@@ -3,18 +3,68 @@ import {
rollInitiativeUseCase,
} from "@initiative/application";
import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { ActionBar } from "./components/action-bar";
import { CombatantRow } from "./components/combatant-row";
import { CreatePlayerModal } from "./components/create-player-modal";
import { PlayerManagement } from "./components/player-management";
import { SourceManager } from "./components/source-manager";
import { StatBlockPanel } from "./components/stat-block-panel";
import { Toast } from "./components/toast";
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 { usePlayerCharacters } from "./hooks/use-player-characters";
function rollDice(): number {
return Math.floor(Math.random() * 20) + 1;
}
function useActionBarAnimation(combatantCount: number) {
const wasEmptyRef = useRef(combatantCount === 0);
const [settling, setSettling] = useState(false);
const [rising, setRising] = useState(false);
const [topBarExiting, setTopBarExiting] = useState(false);
useLayoutEffect(() => {
const nowEmpty = combatantCount === 0;
if (wasEmptyRef.current && !nowEmpty) {
setSettling(true);
} else if (!wasEmptyRef.current && nowEmpty) {
setRising(true);
setTopBarExiting(true);
}
wasEmptyRef.current = nowEmpty;
}, [combatantCount]);
const empty = combatantCount === 0;
const risingClass = rising ? " animate-rise-to-center" : "";
const settlingClass = settling ? " animate-settle-to-bottom" : "";
const topBarClass = settling
? " animate-slide-down-in"
: topBarExiting
? " absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
: "";
const showTopBar = !empty || topBarExiting;
return {
risingClass,
settlingClass,
topBarClass,
showTopBar,
onSettleEnd: () => setSettling(false),
onRiseEnd: () => setRising(false),
onTopBarExitEnd: () => setTopBarExiting(false),
};
}
export function App() {
const {
encounter,
@@ -31,47 +81,74 @@ export function App() {
toggleCondition,
toggleConcentration,
addFromBestiary,
addFromPlayerCharacter,
makeStore,
} = useEncounter();
const { search, getCreature, isLoaded } = useBestiary();
const {
characters: playerCharacters,
createCharacter: createPlayerCharacter,
editCharacter: editPlayerCharacter,
deleteCharacter: deletePlayerCharacter,
} = usePlayerCharacters();
const [selectedCreature, setSelectedCreature] = useState<Creature | null>(
const [createPlayerOpen, setCreatePlayerOpen] = useState(false);
const [managementOpen, setManagementOpen] = useState(false);
const [editingPlayer, setEditingPlayer] = useState<
(typeof playerCharacters)[number] | undefined
>(undefined);
const {
search,
getCreature,
isLoaded,
isSourceCached,
fetchAndCacheSource,
uploadAndCacheSource,
refreshCache,
} = useBestiary();
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,
);
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(
(creature: Creature) => {
addFromBestiary(creature);
setSelectedCreature(creature);
(result: SearchResult) => {
addFromBestiary(result);
},
[addFromBestiary],
);
const handleShowStatBlock = useCallback((creature: Creature) => {
setSelectedCreature(creature);
const handleCombatantStatBlock = useCallback((creatureId: string) => {
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(
(id: CombatantId) => {
rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature);
@@ -83,6 +160,62 @@ export function App() {
rollAllInitiativeUseCase(makeStore(), rollDice, 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);
}, []);
const actionBarInputRef = useRef<HTMLInputElement>(null);
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
// Auto-scroll to the active combatant when the turn changes
const activeRowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -102,78 +235,227 @@ export function App() {
if (!window.matchMedia("(min-width: 1024px)").matches) return;
const active = encounter.combatants[encounter.activeIndex];
if (!active?.creatureId || !isLoaded) return;
const creature = getCreature(active.creatureId as CreatureId);
if (creature) setSelectedCreature(creature);
}, [encounter.activeIndex, encounter.combatants, getCreature, isLoaded]);
setSelectedCreatureId(active.creatureId as CreatureId);
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
const isEmpty = encounter.combatants.length === 0;
const showRollAllInitiative = encounter.combatants.some(
(c) => c.creatureId != null && c.initiative == null,
);
return (
<div className="flex h-screen flex-col">
<div className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0">
{/* Turn Navigation — fixed at top */}
<div className="shrink-0 pt-8">
<TurnNavigation
encounter={encounter}
onAdvanceTurn={advanceTurn}
onRetreatTurn={retreatTurn}
onClearEncounter={clearEncounter}
onRollAllInitiative={handleRollAllInitiative}
/>
</div>
{/* Scrollable area — combatant list */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="flex flex-col pb-2">
{encounter.combatants.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground">
No combatants yet add one to get started
</p>
) : (
encounter.combatants.map((c, i) => (
<CombatantRow
key={c.id}
ref={i === encounter.activeIndex ? activeRowRef : null}
combatant={c}
isActive={i === encounter.activeIndex}
onRename={editCombatant}
onSetInitiative={setInitiative}
onRemove={removeCombatant}
onSetHp={setHp}
onAdjustHp={adjustHp}
onSetAc={setAc}
onToggleCondition={toggleCondition}
onToggleConcentration={toggleConcentration}
onShowStatBlock={
c.creatureId
? () => handleCombatantStatBlock(c.creatureId as string)
: undefined
}
onRollInitiative={
c.creatureId ? handleRollInitiative : undefined
}
/>
))
)}
<div className="relative mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0">
{actionBarAnim.showTopBar && (
<div
className={`shrink-0 pt-8${actionBarAnim.topBarClass}`}
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
>
<TurnNavigation
encounter={encounter}
onAdvanceTurn={advanceTurn}
onRetreatTurn={retreatTurn}
onClearEncounter={clearEncounter}
/>
</div>
</div>
)}
{/* Action Bar — fixed at bottom */}
<div className="shrink-0 pb-8">
<ActionBar
onAddCombatant={addCombatant}
onAddFromBestiary={handleAddFromBestiary}
bestiarySearch={search}
bestiaryLoaded={isLoaded}
suggestions={suggestions}
onSearchChange={handleSearchChange}
onShowStatBlock={handleShowStatBlock}
/>
</div>
{isEmpty ? (
/* Empty state — ActionBar centered */
<div className="flex flex-1 items-center justify-center min-h-0 pb-[15%] pt-8">
<div
className={`w-full${actionBarAnim.risingClass}`}
onAnimationEnd={actionBarAnim.onRiseEnd}
>
<ActionBar
onAddCombatant={addCombatant}
onAddFromBestiary={handleAddFromBestiary}
bestiarySearch={search}
bestiaryLoaded={isLoaded}
onViewStatBlock={handleViewStatBlock}
onBulkImport={handleBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"}
inputRef={actionBarInputRef}
playerCharacters={playerCharacters}
onAddFromPlayerCharacter={addFromPlayerCharacter}
onManagePlayers={() => setManagementOpen(true)}
onRollAllInitiative={handleRollAllInitiative}
showRollAllInitiative={showRollAllInitiative}
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
autoFocus
/>
</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 */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="flex flex-col px-2 py-2">
{encounter.combatants.map((c, i) => (
<CombatantRow
key={c.id}
ref={i === encounter.activeIndex ? activeRowRef : null}
combatant={c}
isActive={i === encounter.activeIndex}
onRename={editCombatant}
onSetInitiative={setInitiative}
onRemove={removeCombatant}
onSetHp={setHp}
onAdjustHp={adjustHp}
onSetAc={setAc}
onToggleCondition={toggleCondition}
onToggleConcentration={toggleConcentration}
onShowStatBlock={
c.creatureId
? () => handleCombatantStatBlock(c.creatureId as string)
: undefined
}
onRollInitiative={
c.creatureId ? handleRollInitiative : undefined
}
/>
))}
</div>
</div>
{/* Action Bar — fixed at bottom */}
<div
className={`shrink-0 pb-8${actionBarAnim.settlingClass}`}
onAnimationEnd={actionBarAnim.onSettleEnd}
>
<ActionBar
onAddCombatant={addCombatant}
onAddFromBestiary={handleAddFromBestiary}
bestiarySearch={search}
bestiaryLoaded={isLoaded}
onViewStatBlock={handleViewStatBlock}
onBulkImport={handleBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"}
inputRef={actionBarInputRef}
playerCharacters={playerCharacters}
onAddFromPlayerCharacter={addFromPlayerCharacter}
onManagePlayers={() => setManagementOpen(true)}
onRollAllInitiative={handleRollAllInitiative}
showRollAllInitiative={showRollAllInitiative}
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
/>
</div>
</>
)}
</div>
{/* Stat Block Panel */}
{/* Pinned Stat Block Panel (left) */}
{pinnedCreatureId && isWideDesktop && (
<StatBlockPanel
creatureId={pinnedCreatureId}
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}
onClose={() => setSelectedCreature(null)}
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}
/>
)}
<CreatePlayerModal
open={createPlayerOpen}
onClose={() => {
setCreatePlayerOpen(false);
setEditingPlayer(undefined);
}}
onSave={(name, ac, maxHp, color, icon) => {
if (editingPlayer) {
editPlayerCharacter?.(editingPlayer.id, {
name,
ac,
maxHp,
color,
icon,
});
} else {
createPlayerCharacter(name, ac, maxHp, color, icon);
}
}}
playerCharacter={editingPlayer}
/>
<PlayerManagement
open={managementOpen}
onClose={() => setManagementOpen(false)}
characters={playerCharacters}
onEdit={(pc) => {
setEditingPlayer(pc);
setCreatePlayerOpen(true);
setManagementOpen(false);
}}
onDelete={(id) => deletePlayerCharacter?.(id)}
onCreate={() => {
setEditingPlayer(undefined);
setCreatePlayerOpen(true);
setManagementOpen(false);
}}
/>
</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 { normalizeBestiary } from "../bestiary-adapter.js";
import { beforeAll, describe, expect, it } from "vitest";
import {
normalizeBestiary,
setSourceDisplayNames,
} from "../bestiary-adapter.js";
beforeAll(() => {
setSourceDisplayNames({ XMM: "MM 2024" });
});
describe("normalizeBestiary", () => {
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[];
type: string | { type: string; tags?: string[]; swarmSize?: string };
alignment?: string[];
ac: (number | { ac: number; from?: string[] })[];
hp: { average: number; formula: string };
ac: (number | { ac: number; from?: string[] } | { special: string })[];
hp: { average?: number; formula?: string; special?: string };
speed: Record<
string,
number | { number: number; condition?: string } | boolean
@@ -38,7 +38,7 @@ interface RawMonster {
vulnerable?: (string | { special: string })[];
conditionImmune?: string[];
languages?: string[];
cr: string | { cr: string };
cr?: string | { cr: string };
trait?: RawEntry[];
action?: RawEntry[];
bonus?: RawEntry[];
@@ -81,9 +81,11 @@ interface RawSpellcasting {
// --- Source mapping ---
const SOURCE_DISPLAY_NAMES: Record<string, string> = {
XMM: "MM 2024",
};
let sourceDisplayNames: Record<string, string> = {};
export function setSourceDisplayNames(names: Record<string, string>): void {
sourceDisplayNames = names;
}
// --- Size mapping ---
@@ -138,7 +140,12 @@ function formatType(
let result = baseType;
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) {
const swarmSizeLabel = SIZE_MAP[type.swarmSize] ?? type.swarmSize;
@@ -159,6 +166,14 @@ function extractAc(ac: RawMonster["ac"]): {
if (typeof first === "number") {
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 {
value: first.ac,
source: first.from ? stripTags(first.from.join(", ")) : undefined,
@@ -239,25 +254,36 @@ function formatConditionImmunities(
.join(", ");
}
function renderListItem(item: string | RawEntryObject): string | undefined {
if (typeof item === "string") {
return `${stripTags(item)}`;
}
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) {
parts.push(`${stripTags(entry.name)}: ${renderEntries(entry.entries)}`);
} else if (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 if (entry.type === "list") {
for (const item of entry.items ?? []) {
if (typeof item === "string") {
parts.push(`${stripTags(item)}`);
} else if (item.name && item.entries) {
parts.push(
`${stripTags(item.name)}: ${renderEntries(item.entries)}`,
);
}
}
} else if (entry.type === "item" && entry.name && entry.entries) {
parts.push(`${stripTags(entry.name)}: ${renderEntries(entry.entries)}`);
} else if (entry.entries) {
parts.push(renderEntries(entry.entries));
} else {
renderEntryObject(entry, parts);
}
}
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;
}
@@ -353,54 +380,81 @@ function makeCreatureId(source: string, name: string): CreatureId {
* Normalizes raw 5etools bestiary JSON into domain Creature[].
*/
export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
return raw.monster.map((m) => {
const crStr = extractCr(m.cr);
const ac = extractAc(m.ac);
return {
id: makeCreatureId(m.source, m.name),
name: m.name,
source: m.source,
sourceDisplayName: SOURCE_DISPLAY_NAMES[m.source] ?? m.source,
size: formatSize(m.size),
type: formatType(m.type),
alignment: formatAlignment(m.alignment),
ac: ac.value,
acSource: ac.source,
hp: { average: m.hp.average, formula: m.hp.formula },
speed: formatSpeed(m.speed),
abilities: {
str: m.str,
dex: m.dex,
con: m.con,
int: m.int,
wis: m.wis,
cha: m.cha,
},
cr: crStr,
initiativeProficiency: m.initiative?.proficiency ?? 0,
proficiencyBonus: proficiencyBonus(crStr),
passive: m.passive,
savingThrows: formatSaves(m.save),
skills: formatSkills(m.skill),
resist: formatDamageList(m.resist),
immune: formatDamageList(m.immune),
vulnerable: formatDamageList(m.vulnerable),
conditionImmune: formatConditionImmunities(m.conditionImmune),
senses:
m.senses && m.senses.length > 0
? m.senses.map((s) => stripTags(s)).join(", ")
: undefined,
languages:
m.languages && m.languages.length > 0
? m.languages.join(", ")
: undefined,
traits: normalizeTraits(m.trait),
actions: normalizeTraits(m.action),
bonusActions: normalizeTraits(m.bonus),
reactions: normalizeTraits(m.reaction),
legendaryActions: normalizeLegendary(m.legendary, m),
spellcasting: normalizeSpellcasting(m.spellcasting),
};
// 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 ac = extractAc(m.ac);
return {
id: makeCreatureId(m.source, m.name),
name: m.name,
source: m.source,
sourceDisplayName: sourceDisplayNames[m.source] ?? m.source,
size: formatSize(m.size),
type: formatType(m.type),
alignment: formatAlignment(m.alignment),
ac: ac.value,
acSource: ac.source,
hp: {
average: m.hp.average ?? 0,
formula: m.hp.formula ?? m.hp.special ?? "",
},
speed: formatSpeed(m.speed),
abilities: {
str: m.str,
dex: m.dex,
con: m.con,
int: m.int,
wis: m.wis,
cha: m.cha,
},
cr: crStr,
initiativeProficiency: m.initiative?.proficiency ?? 0,
proficiencyBonus: proficiencyBonus(crStr),
passive: m.passive,
savingThrows: formatSaves(m.save),
skills: formatSkills(m.skill),
resist: formatDamageList(m.resist),
immune: formatDamageList(m.immune),
vulnerable: formatDamageList(m.vulnerable),
conditionImmune: formatConditionImmunities(m.conditionImmune),
senses:
m.senses && m.senses.length > 0
? m.senses.map((s) => stripTags(s)).join(", ")
: undefined,
languages:
m.languages && m.languages.length > 0
? m.languages.join(", ")
: undefined,
traits: normalizeTraits(m.trait),
actions: normalizeTraits(m.action),
bonusActions: normalizeTraits(m.bonus),
reactions: normalizeTraits(m.reaction),
legendaryActions: normalizeLegendary(m.legendary, m),
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.
*/
export function stripTags(text: string): string {
if (typeof text !== "string") return String(text);
// Process special tags with specific output formats first
let result = text;

View File

@@ -0,0 +1,199 @@
// @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()}
/>,
);
}
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 siblings in the center area", () => {
renderNav();
const badge = screen.getByText("R1");
const name = screen.getByText("Goblin");
expect(badge.parentElement).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()}
/>,
);
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()}
/>,
);
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()}
/>,
);
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()}
/>,
);
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();
});
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,246 @@
import type { Creature } from "@initiative/domain";
import { Search } from "lucide-react";
import { type FormEvent, useState } from "react";
import { BestiarySearch } from "./bestiary-search.js";
import type { PlayerCharacter, PlayerIcon } from "@initiative/domain";
import {
Check,
Eye,
EyeOff,
Import,
Library,
Minus,
Plus,
Users,
} from "lucide-react";
import { type FormEvent, type RefObject, useState } from "react";
import type { SearchResult } from "../hooks/use-bestiary.js";
import { cn } from "../lib/utils.js";
import { D20Icon } from "./d20-icon.js";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
interface QueuedCreature {
result: SearchResult;
count: number;
}
interface ActionBarProps {
onAddCombatant: (name: string) => void;
onAddFromBestiary: (creature: Creature) => void;
bestiarySearch: (query: string) => Creature[];
onAddCombatant: (
name: string,
opts?: { initiative?: number; ac?: number; maxHp?: number },
) => void;
onAddFromBestiary: (result: SearchResult) => void;
bestiarySearch: (query: string) => SearchResult[];
bestiaryLoaded: boolean;
suggestions: Creature[];
onSearchChange: (query: string) => void;
onShowStatBlock?: (creature: Creature) => void;
onViewStatBlock?: (result: SearchResult) => void;
onBulkImport?: () => void;
bulkImportDisabled?: boolean;
inputRef?: RefObject<HTMLInputElement | null>;
playerCharacters?: readonly PlayerCharacter[];
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
onManagePlayers?: () => void;
onRollAllInitiative?: () => void;
showRollAllInitiative?: boolean;
onOpenSourceManager?: () => void;
autoFocus?: boolean;
}
function creatureKey(r: SearchResult): string {
return `${r.source}:${r.name}`;
}
function AddModeSuggestions({
nameInput,
suggestions,
pcMatches,
suggestionIndex,
queued,
onDismiss,
onClickSuggestion,
onSetSuggestionIndex,
onSetQueued,
onConfirmQueued,
onAddFromPlayerCharacter,
}: {
nameInput: string;
suggestions: SearchResult[];
pcMatches: PlayerCharacter[];
suggestionIndex: number;
queued: QueuedCreature | null;
onDismiss: () => void;
onClickSuggestion: (result: SearchResult) => void;
onSetSuggestionIndex: (i: number) => void;
onSetQueued: (q: QueuedCreature | null) => void;
onConfirmQueued: () => void;
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
}) {
return (
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
<button
type="button"
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-2 text-left text-sm text-accent hover:bg-accent/20"
onMouseDown={(e) => e.preventDefault()}
onClick={onDismiss}
>
<Plus className="h-3.5 w-3.5" />
<span className="flex-1">Add "{nameInput}" as custom</span>
<kbd className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground">
Esc
</kbd>
</button>
<div className="max-h-48 overflow-y-auto py-1">
{pcMatches.length > 0 && (
<>
<div className="px-3 py-1 text-xs font-medium text-muted-foreground">
Players
</div>
<ul>
{pcMatches.map((pc) => {
const PcIcon = PLAYER_ICON_MAP[pc.icon as PlayerIcon];
const pcColor =
PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX];
return (
<li key={pc.id}>
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-hover-neutral-bg"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onAddFromPlayerCharacter?.(pc);
onDismiss();
}}
>
{PcIcon && (
<PcIcon size={14} style={{ color: pcColor }} />
)}
<span className="flex-1 truncate">{pc.name}</span>
<span className="text-xs text-muted-foreground">
Player
</span>
</button>
</li>
);
})}
</ul>
</>
)}
{suggestions.length > 0 && (
<ul>
{suggestions.map((result, i) => {
const key = creatureKey(result);
const isQueued =
queued !== null && creatureKey(queued.result) === key;
return (
<li key={key}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
isQueued
? "bg-accent/30 text-foreground"
: i === suggestionIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onClickSuggestion(result)}
onMouseEnter={() => onSetSuggestionIndex(i)}
>
<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) {
onSetQueued(null);
} else {
onSetQueued({
...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();
onSetQueued({
...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();
onConfirmQueued();
}}
>
<Check className="h-3.5 w-3.5" />
</button>
</>
) : (
result.sourceDisplayName
)}
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
</div>
);
}
function buildOverflowItems(opts: {
onManagePlayers?: () => void;
onOpenSourceManager?: () => void;
bestiaryLoaded: boolean;
onBulkImport?: () => void;
bulkImportDisabled?: boolean;
}): OverflowMenuItem[] {
const items: OverflowMenuItem[] = [];
if (opts.onManagePlayers) {
items.push({
icon: <Users className="h-4 w-4" />,
label: "Player Characters",
onClick: opts.onManagePlayers,
});
}
if (opts.onOpenSourceManager) {
items.push({
icon: <Library className="h-4 w-4" />,
label: "Manage Sources",
onClick: opts.onOpenSourceManager,
});
}
if (opts.bestiaryLoaded && opts.onBulkImport) {
items.push({
icon: <Import className="h-4 w-4" />,
label: "Bulk Import",
onClick: opts.onBulkImport,
disabled: opts.bulkImportDisabled,
});
}
return items;
}
export function ActionBar({
@@ -20,46 +248,171 @@ export function ActionBar({
onAddFromBestiary,
bestiarySearch,
bestiaryLoaded,
suggestions,
onSearchChange,
onShowStatBlock,
onViewStatBlock,
onBulkImport,
bulkImportDisabled,
inputRef,
playerCharacters,
onAddFromPlayerCharacter,
onManagePlayers,
onRollAllInitiative,
showRollAllInitiative,
onOpenSourceManager,
autoFocus,
}: ActionBarProps) {
const [nameInput, setNameInput] = useState("");
const [searchOpen, setSearchOpen] = useState(false);
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
const [suggestionIndex, setSuggestionIndex] = useState(-1);
const [queued, setQueued] = useState<QueuedCreature | null>(null);
const [customInit, setCustomInit] = useState("");
const [customAc, setCustomAc] = useState("");
const [customMaxHp, setCustomMaxHp] = useState("");
const [browseMode, setBrowseMode] = useState(false);
const clearCustomFields = () => {
setCustomInit("");
setCustomAc("");
setCustomMaxHp("");
};
const clearInput = () => {
setNameInput("");
setSuggestions([]);
setPcMatches([]);
setQueued(null);
setSuggestionIndex(-1);
};
const dismissSuggestions = () => {
setSuggestions([]);
setPcMatches([]);
setQueued(null);
setSuggestionIndex(-1);
};
const confirmQueued = () => {
if (!queued) return;
for (let i = 0; i < queued.count; i++) {
onAddFromBestiary(queued.result);
}
clearInput();
};
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) => {
e.preventDefault();
if (browseMode) return;
if (queued) {
confirmQueued();
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("");
onSearchChange("");
setSuggestions([]);
setPcMatches([]);
clearCustomFields();
};
const handleBrowseSearch = (value: string) => {
setSuggestions(value.length >= 2 ? bestiarySearch(value) : []);
};
const handleAddSearch = (value: string) => {
let newSuggestions: SearchResult[] = [];
let newPcMatches: PlayerCharacter[] = [];
if (value.length >= 2) {
newSuggestions = bestiarySearch(value);
setSuggestions(newSuggestions);
if (playerCharacters && playerCharacters.length > 0) {
const lower = value.toLowerCase();
newPcMatches = playerCharacters.filter((pc) =>
pc.name.toLowerCase().includes(lower),
);
}
setPcMatches(newPcMatches);
} else {
setSuggestions([]);
setPcMatches([]);
}
if (newSuggestions.length > 0 || newPcMatches.length > 0) {
clearCustomFields();
}
if (queued) {
const qKey = creatureKey(queued.result);
const stillVisible = newSuggestions.some((s) => creatureKey(s) === qKey);
if (!stillVisible) {
setQueued(null);
}
}
};
const handleNameChange = (value: string) => {
setNameInput(value);
setSuggestionIndex(-1);
onSearchChange(value);
if (browseMode) {
handleBrowseSearch(value);
} else {
handleAddSearch(value);
}
};
const handleSelectCreature = (creature: Creature) => {
onAddFromBestiary(creature);
setSearchOpen(false);
setNameInput("");
onSearchChange("");
onShowStatBlock?.(creature);
const handleClickSuggestion = (result: SearchResult) => {
const key = creatureKey(result);
if (queued && creatureKey(queued.result) === key) {
setQueued({ ...queued, count: queued.count + 1 });
} else {
setQueued({ result, count: 1 });
}
};
const handleSelectSuggestion = (creature: Creature) => {
onAddFromBestiary(creature);
setNameInput("");
onSearchChange("");
onShowStatBlock?.(creature);
const handleEnter = () => {
if (queued) {
confirmQueued();
} else if (suggestionIndex >= 0) {
handleClickSuggestion(suggestions[suggestionIndex]);
}
};
const hasSuggestions = suggestions.length > 0 || pcMatches.length > 0;
const handleKeyDown = (e: React.KeyboardEvent) => {
if (suggestions.length === 0) return;
if (!hasSuggestions) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
} else if (e.key === "Enter") {
e.preventDefault();
handleEnter();
} else if (e.key === "Escape") {
dismissSuggestions();
}
};
const handleBrowseKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
setBrowseMode(false);
clearInput();
return;
}
if (suggestions.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
@@ -68,40 +421,78 @@ export function ActionBar({
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
} else if (e.key === "Enter" && suggestionIndex >= 0) {
e.preventDefault();
handleSelectSuggestion(suggestions[suggestionIndex]);
} else if (e.key === "Escape") {
setSuggestionIndex(-1);
onSearchChange("");
onViewStatBlock?.(suggestions[suggestionIndex]);
setBrowseMode(false);
clearInput();
}
};
const handleBrowseSelect = (result: SearchResult) => {
onViewStatBlock?.(result);
setBrowseMode(false);
clearInput();
};
const toggleBrowseMode = () => {
setBrowseMode((m) => !m);
clearInput();
clearCustomFields();
};
const overflowItems = buildOverflowItems({
onManagePlayers,
onOpenSourceManager,
bestiaryLoaded,
onBulkImport,
bulkImportDisabled,
});
return (
<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
onSubmit={handleAdd}
className="relative flex flex-1 items-center gap-2"
>
<div className="relative flex-1">
<form
onSubmit={handleAdd}
className="relative flex flex-1 items-center gap-2"
>
<div className="flex-1">
<div className="relative max-w-xs">
<Input
ref={inputRef}
type="text"
value={nameInput}
onChange={(e) => handleNameChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Combatant name"
className="max-w-xs"
onKeyDown={browseMode ? handleBrowseKeyDown : handleKeyDown}
placeholder={
browseMode ? "Search stat blocks..." : "+ Add combatants"
}
className="pr-8"
autoFocus={autoFocus}
/>
{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">
{bestiaryLoaded && onViewStatBlock && (
<button
type="button"
tabIndex={-1}
className={cn(
"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
browseMode && "text-accent",
)}
onClick={toggleBrowseMode}
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
aria-label={
browseMode ? "Switch to add mode" : "Browse stat blocks"
}
>
{browseMode ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
)}
{browseMode && suggestions.length > 0 && (
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
<ul className="max-h-48 overflow-y-auto py-1">
{suggestions.map((creature, i) => (
<li key={creature.id}>
{suggestions.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 ${
@@ -109,12 +500,13 @@ export function ActionBar({
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
}`}
onClick={() => handleSelectSuggestion(creature)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleBrowseSelect(result)}
onMouseEnter={() => setSuggestionIndex(i)}
>
<span>{creature.name}</span>
<span>{result.name}</span>
<span className="text-xs text-muted-foreground">
{creature.sourceDisplayName}
{result.sourceDisplayName}
</span>
</button>
</li>
@@ -122,22 +514,69 @@ export function ActionBar({
</ul>
</div>
)}
{!browseMode && hasSuggestions && (
<AddModeSuggestions
nameInput={nameInput}
suggestions={suggestions}
pcMatches={pcMatches}
suggestionIndex={suggestionIndex}
queued={queued}
onDismiss={dismissSuggestions}
onClickSuggestion={handleClickSuggestion}
onSetSuggestionIndex={setSuggestionIndex}
onSetQueued={setQueued}
onConfirmQueued={confirmQueued}
onAddFromPlayerCharacter={onAddFromPlayerCharacter}
/>
)}
</div>
<Button type="submit" size="sm">
Add
</div>
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
<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>
)}
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
<Button type="submit">Add</Button>
)}
{showRollAllInitiative && onRollAllInitiative && (
<Button
type="button"
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-hover-action"
onClick={onRollAllInitiative}
title="Roll all initiative"
aria-label="Roll all initiative"
>
<D20Icon className="h-6 w-6" />
</Button>
{bestiaryLoaded && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setSearchOpen(true)}
>
<Search className="h-4 w-4" />
</Button>
)}
</form>
)}
)}
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
</form>
</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,107 @@
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 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 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 onClick={() => onStartImport(baseUrl)} disabled={isDisabled}>
Load All
</Button>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { VALID_PLAYER_COLORS } from "@initiative/domain";
import { cn } from "../lib/utils";
import { PLAYER_COLOR_HEX } from "./player-icon-map";
interface ColorPaletteProps {
value: string;
onChange: (color: string) => void;
}
const COLORS = [...VALID_PLAYER_COLORS] as string[];
export function ColorPalette({ value, onChange }: ColorPaletteProps) {
return (
<div className="flex flex-wrap gap-2">
{COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => onChange(color)}
className={cn(
"h-8 w-8 rounded-full transition-all",
value === color
? "ring-2 ring-foreground ring-offset-2 ring-offset-background scale-110"
: "hover:scale-110",
)}
style={{
backgroundColor:
PLAYER_COLOR_HEX[color as keyof typeof PLAYER_COLOR_HEX],
}}
aria-label={color}
title={color}
/>
))}
</div>
);
}

View File

@@ -2,6 +2,7 @@ import {
type CombatantId,
type ConditionId,
deriveHpStatus,
type PlayerIcon,
} from "@initiative/domain";
import { Brain, X } from "lucide-react";
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
@@ -11,7 +12,8 @@ import { ConditionPicker } from "./condition-picker";
import { ConditionTags } from "./condition-tags";
import { D20Icon } from "./d20-icon";
import { HpAdjustPopover } from "./hp-adjust-popover";
import { Button } from "./ui/button";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
import { ConfirmButton } from "./ui/confirm-button";
import { Input } from "./ui/input";
interface Combatant {
@@ -23,6 +25,8 @@ interface Combatant {
readonly ac?: number;
readonly conditions?: readonly ConditionId[];
readonly isConcentrating?: boolean;
readonly color?: string;
readonly icon?: string;
}
interface CombatantRowProps {
@@ -44,14 +48,21 @@ function EditableName({
name,
combatantId,
onRename,
onShowStatBlock,
color,
}: {
name: string;
combatantId: CombatantId;
onRename: (id: CombatantId, newName: string) => void;
onShowStatBlock?: () => void;
color?: string;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(name);
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 trimmed = draft.trim();
@@ -67,6 +78,46 @@ function EditableName({
requestAnimationFrame(() => inputRef.current?.select());
}, [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) {
return (
<Input
@@ -85,16 +136,20 @@ function EditableName({
}
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
startEditing();
}}
className="truncate text-left text-sm text-foreground hover:text-hover-neutral transition-colors"
>
{name}
</button>
<>
<button
type="button"
onClick={handleClick}
onTouchStart={handleTouchStart}
onTouchEnd={cancelLongPress}
onTouchCancel={cancelLongPress}
onTouchMove={cancelLongPress}
className="truncate text-left text-sm text-foreground cursor-text hover:text-hover-neutral transition-colors"
style={color ? { color } : undefined}
>
{name}
</button>
</>
);
}
@@ -354,6 +409,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({
ref,
combatant,
@@ -401,22 +485,26 @@ export function CombatantRow({
}
}, [combatant.isConcentrating]);
const pcColor = combatant.color
? PLAYER_COLOR_HEX[combatant.color as keyof typeof PLAYER_COLOR_HEX]
: undefined;
return (
/* biome-ignore lint/a11y/useKeyWithClickEvents: row click opens stat block */
/* biome-ignore lint/a11y/noStaticElementInteractions: row click opens stat block */
/* biome-ignore lint/a11y/noStaticElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
<div
ref={ref}
role={onShowStatBlock ? "button" : undefined}
tabIndex={onShowStatBlock ? 0 : undefined}
className={cn(
"group rounded-md pr-3 transition-colors",
isActive
? "border-l-2 border-l-accent bg-accent/10"
: combatant.isConcentrating
? "border-l-2 border-l-purple-400"
: "border-l-2 border-l-transparent",
rowBorderClass(isActive, combatant.isConcentrating),
isPulsing && "animate-concentration-pulse",
onShowStatBlock && "cursor-pointer",
)}
onClick={onShowStatBlock}
onKeyDown={
onShowStatBlock ? activateOnKeyDown(onShowStatBlock) : undefined
}
>
<div className="grid grid-cols-[2rem_3rem_1fr_auto_auto_2rem] items-center gap-3 py-2">
{/* Concentration */}
@@ -430,20 +518,18 @@ export function CombatantRow({
aria-label="Toggle concentration"
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",
combatant.isConcentrating
? dimmed
? "opacity-50 text-purple-400"
: "opacity-100 text-purple-400"
: "opacity-0 group-hover:opacity-50 text-muted-foreground",
concentrationIconClass(combatant.isConcentrating, dimmed),
)}
>
<Brain size={16} />
</button>
{/* Initiative */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation wrapper */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper */}
<div onClick={(e) => e.stopPropagation()}>
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
<div
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<InitiativeDisplay
initiative={initiative}
combatantId={id}
@@ -460,9 +546,29 @@ export function CombatantRow({
dimmed && "opacity-50",
)}
>
<span className="min-w-0 truncate">
<EditableName name={name} combatantId={id} onRename={onRename} />
</span>
{combatant.icon &&
combatant.color &&
(() => {
const PcIcon = PLAYER_ICON_MAP[combatant.icon as PlayerIcon];
const pcColor =
PLAYER_COLOR_HEX[
combatant.color as keyof typeof PLAYER_COLOR_HEX
];
return PcIcon ? (
<PcIcon
size={14}
style={{ color: pcColor }}
className="shrink-0"
/>
) : null;
})()}
<EditableName
name={name}
combatantId={id}
onRename={onRename}
onShowStatBlock={onShowStatBlock}
color={pcColor}
/>
<ConditionTags
conditions={combatant.conditions}
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
@@ -478,21 +584,21 @@ export function CombatantRow({
</div>
{/* AC */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation wrapper */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
<div
className={cn(dimmed && "opacity-50")}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
</div>
{/* HP */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation wrapper */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
<div
className="flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<ClickableHp
currentHp={currentHp}
@@ -516,19 +622,12 @@ export function CombatantRow({
</div>
{/* Actions */}
<Button
variant="ghost"
size="icon"
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"
onClick={(e) => {
e.stopPropagation();
onRemove(id);
}}
title="Remove combatant"
aria-label="Remove combatant"
>
<X size={16} />
</Button>
<ConfirmButton
icon={<X size={16} />}
label="Remove combatant"
onConfirm={() => onRemove(id)}
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"
/>
</div>
</div>
);

View File

@@ -64,12 +64,21 @@ export function ConditionPicker({
}: ConditionPickerProps) {
const ref = useRef<HTMLDivElement>(null);
const [flipped, setFlipped] = useState(false);
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
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(() => {
@@ -88,9 +97,10 @@ export function ConditionPicker({
<div
ref={ref}
className={cn(
"absolute z-10 w-fit rounded-md border border-border bg-background p-1 shadow-lg",
flipped ? "bottom-full mb-1" : "mt-1",
"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" : "top-full mt-1",
)}
style={maxHeight ? { maxHeight } : undefined}
>
{CONDITION_DEFINITIONS.map((def) => {
const Icon = ICON_MAP[def.iconName];

View File

@@ -89,7 +89,7 @@ export function ConditionTags({
type="button"
title="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) => {
e.stopPropagation();
onOpenPicker();

View File

@@ -0,0 +1,187 @@
import type { PlayerCharacter } from "@initiative/domain";
import { X } from "lucide-react";
import { type FormEvent, useEffect, useState } from "react";
import { ColorPalette } from "./color-palette";
import { IconGrid } from "./icon-grid";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
interface CreatePlayerModalProps {
open: boolean;
onClose: () => void;
onSave: (
name: string,
ac: number,
maxHp: number,
color: string,
icon: string,
) => void;
playerCharacter?: PlayerCharacter;
}
export function CreatePlayerModal({
open,
onClose,
onSave,
playerCharacter,
}: CreatePlayerModalProps) {
const [name, setName] = useState("");
const [ac, setAc] = useState("10");
const [maxHp, setMaxHp] = useState("10");
const [color, setColor] = useState("blue");
const [icon, setIcon] = useState("sword");
const [error, setError] = useState("");
const isEdit = !!playerCharacter;
useEffect(() => {
if (open) {
if (playerCharacter) {
setName(playerCharacter.name);
setAc(String(playerCharacter.ac));
setMaxHp(String(playerCharacter.maxHp));
setColor(playerCharacter.color);
setIcon(playerCharacter.icon);
} else {
setName("");
setAc("10");
setMaxHp("10");
setColor("blue");
setIcon("sword");
}
setError("");
}
}, [open, playerCharacter]);
useEffect(() => {
if (!open) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, onClose]);
if (!open) return null;
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (trimmed === "") {
setError("Name is required");
return;
}
const acNum = Number.parseInt(ac, 10);
if (Number.isNaN(acNum) || acNum < 0) {
setError("AC must be a non-negative number");
return;
}
const hpNum = Number.parseInt(maxHp, 10);
if (Number.isNaN(hpNum) || hpNum < 1) {
setError("Max HP must be at least 1");
return;
}
onSave(trimmed, acNum, hpNum, color, icon);
onClose();
};
return (
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onMouseDown={onClose}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
<div
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">
{isEdit ? "Edit Player" : "Create Player"}
</h2>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="text-muted-foreground"
>
<X size={20} />
</Button>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<span className="mb-1 block text-sm text-muted-foreground">
Name
</span>
<Input
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
setError("");
}}
placeholder="Character name"
aria-label="Name"
autoFocus
/>
{error && <p className="mt-1 text-sm text-destructive">{error}</p>}
</div>
<div className="flex gap-3">
<div className="flex-1">
<span className="mb-1 block text-sm text-muted-foreground">
AC
</span>
<Input
type="text"
inputMode="numeric"
value={ac}
onChange={(e) => setAc(e.target.value)}
placeholder="AC"
aria-label="AC"
className="text-center"
/>
</div>
<div className="flex-1">
<span className="mb-1 block text-sm text-muted-foreground">
Max HP
</span>
<Input
type="text"
inputMode="numeric"
value={maxHp}
onChange={(e) => setMaxHp(e.target.value)}
placeholder="Max HP"
aria-label="Max HP"
className="text-center"
/>
</div>
</div>
<div>
<span className="mb-2 block text-sm text-muted-foreground">
Color
</span>
<ColorPalette value={color} onChange={setColor} />
</div>
<div>
<span className="mb-2 block text-sm text-muted-foreground">
Icon
</span>
<IconGrid value={icon} onChange={setIcon} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -6,7 +6,6 @@ import {
useRef,
useState,
} from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
interface HpAdjustPopoverProps {
@@ -109,30 +108,26 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
}}
onKeyDown={handleKeyDown}
/>
<Button
<button
type="button"
variant="ghost"
size="icon"
disabled={!isValid}
className="h-7 w-7 shrink-0 text-red-400 hover:bg-red-950 hover:text-red-300"
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-red-400 transition-colors hover:bg-red-950 hover:text-red-300 disabled:pointer-events-none disabled:opacity-50"
onClick={() => applyDelta(-1)}
title="Apply damage"
aria-label="Apply damage"
>
<Sword size={14} />
</Button>
<Button
</button>
<button
type="button"
variant="ghost"
size="icon"
disabled={!isValid}
className="h-7 w-7 shrink-0 text-emerald-400 hover:bg-emerald-950 hover:text-emerald-300"
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-emerald-400 transition-colors hover:bg-emerald-950 hover:text-emerald-300 disabled:pointer-events-none disabled:opacity-50"
onClick={() => applyDelta(1)}
title="Apply healing"
aria-label="Apply healing"
>
<Heart size={14} />
</Button>
</button>
</div>
</div>
);

View File

@@ -0,0 +1,38 @@
import type { PlayerIcon } from "@initiative/domain";
import { VALID_PLAYER_ICONS } from "@initiative/domain";
import { cn } from "../lib/utils";
import { PLAYER_ICON_MAP } from "./player-icon-map";
interface IconGridProps {
value: string;
onChange: (icon: string) => void;
}
const ICONS = [...VALID_PLAYER_ICONS] as PlayerIcon[];
export function IconGrid({ value, onChange }: IconGridProps) {
return (
<div className="flex flex-wrap gap-2">
{ICONS.map((iconId) => {
const Icon = PLAYER_ICON_MAP[iconId];
return (
<button
key={iconId}
type="button"
onClick={() => onChange(iconId)}
className={cn(
"flex h-9 w-9 items-center justify-center rounded-md transition-all",
value === iconId
? "bg-primary/20 ring-2 ring-primary text-foreground"
: "text-muted-foreground hover:bg-card hover:text-foreground",
)}
aria-label={iconId}
title={iconId}
>
<Icon size={20} />
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,50 @@
import type { PlayerColor, PlayerIcon } from "@initiative/domain";
import type { LucideIcon } from "lucide-react";
import {
Axe,
Crosshair,
Crown,
Eye,
Feather,
Flame,
Heart,
Moon,
Shield,
Skull,
Star,
Sun,
Sword,
Wand,
Zap,
} from "lucide-react";
export const PLAYER_ICON_MAP: Record<PlayerIcon, LucideIcon> = {
sword: Sword,
shield: Shield,
skull: Skull,
heart: Heart,
wand: Wand,
flame: Flame,
crown: Crown,
star: Star,
moon: Moon,
sun: Sun,
axe: Axe,
crosshair: Crosshair,
eye: Eye,
feather: Feather,
zap: Zap,
};
export const PLAYER_COLOR_HEX: Record<PlayerColor, string> = {
red: "#ef4444",
blue: "#3b82f6",
green: "#22c55e",
purple: "#a855f7",
orange: "#f97316",
pink: "#ec4899",
cyan: "#06b6d4",
yellow: "#eab308",
emerald: "#10b981",
indigo: "#6366f1",
};

View File

@@ -0,0 +1,126 @@
import type {
PlayerCharacter,
PlayerCharacterId,
PlayerIcon,
} from "@initiative/domain";
import { Pencil, Plus, Trash2, X } from "lucide-react";
import { useEffect } from "react";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
import { Button } from "./ui/button";
import { ConfirmButton } from "./ui/confirm-button";
interface PlayerManagementProps {
open: boolean;
onClose: () => void;
characters: readonly PlayerCharacter[];
onEdit: (pc: PlayerCharacter) => void;
onDelete: (id: PlayerCharacterId) => void;
onCreate: () => void;
}
export function PlayerManagement({
open,
onClose,
characters,
onEdit,
onDelete,
onCreate,
}: PlayerManagementProps) {
useEffect(() => {
if (!open) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, onClose]);
if (!open) return null;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onMouseDown={onClose}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
<div
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">
Player Characters
</h2>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="text-muted-foreground"
>
<X size={20} />
</Button>
</div>
{characters.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-8 text-center">
<p className="text-muted-foreground">No player characters yet</p>
<Button onClick={onCreate}>
<Plus size={16} />
Create your first player character
</Button>
</div>
) : (
<div className="flex flex-col gap-1">
{characters.map((pc) => {
const Icon = PLAYER_ICON_MAP[pc.icon as PlayerIcon];
const color =
PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX];
return (
<div
key={pc.id}
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-hover-neutral-bg"
>
{Icon && (
<Icon size={18} style={{ color }} className="shrink-0" />
)}
<span className="flex-1 truncate text-sm text-foreground">
{pc.name}
</span>
<span className="text-xs tabular-nums text-muted-foreground">
AC {pc.ac}
</span>
<span className="text-xs tabular-nums text-muted-foreground">
HP {pc.maxHp}
</span>
<Button
variant="ghost"
size="icon-sm"
onClick={() => onEdit(pc)}
className="text-muted-foreground"
title="Edit"
>
<Pencil size={14} />
</Button>
<ConfirmButton
icon={<Trash2 size={14} />}
label="Delete player character"
onConfirm={() => onDelete(pc.id)}
size="icon-sm"
className="text-muted-foreground"
/>
</div>
);
})}
<div className="mt-2 flex justify-end">
<Button onClick={onCreate} variant="ghost">
<Plus size={16} />
Add
</Button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,126 @@
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 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
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,85 @@
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
variant="outline"
className="hover:text-hover-destructive hover:border-hover-destructive"
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="rounded-md p-1 text-muted-foreground transition-colors hover:bg-hover-destructive-bg hover:text-hover-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
</div>
);
}

View File

@@ -1,17 +1,247 @@
import type { Creature } from "@initiative/domain";
import { X } from "lucide-react";
import type { Creature, CreatureId } from "@initiative/domain";
import { PanelRightClose, Pin, PinOff } from "lucide-react";
import type { ReactNode } 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 { Button } from "./ui/button.js";
interface StatBlockPanelProps {
creatureId: CreatureId | 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
variant="ghost"
size="icon-sm"
onClick={onToggleFold}
className="text-muted-foreground"
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
variant="ghost"
size="icon-sm"
onClick={onPin}
className="text-muted-foreground"
aria-label="Pin creature"
>
<Pin className="h-4 w-4" />
</Button>
)}
{panelRole === "pinned" && (
<Button
variant="ghost"
size="icon-sm"
onClick={onUnpin}
className="text-muted-foreground"
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
variant="ghost"
size="icon-sm"
onClick={onDismiss}
className="text-muted-foreground"
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(
() => window.matchMedia("(min-width: 1024px)").matches,
);
const [needsFetch, setNeedsFetch] = useState(false);
const [checkingCache, setCheckingCache] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(min-width: 1024px)");
@@ -20,58 +250,100 @@ export function StatBlockPanel({ creature, onClose }: StatBlockPanelProps) {
return () => mq.removeEventListener("change", handler);
}, []);
if (!creature) return null;
useEffect(() => {
if (!creatureId || creature) {
setNeedsFetch(false);
return;
}
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 (
<BulkImportPrompt
importState={bulkImportState}
onStartImport={onStartBulkImport}
onDone={onBulkImportDone}
/>
);
}
if (checkingCache) {
return (
<div className="p-4 text-sm text-muted-foreground">Loading...</div>
);
}
if (creature) {
return <StatBlock creature={creature} />;
}
if (needsFetch && sourceCode) {
return (
<SourceFetchPrompt
sourceCode={sourceCode}
sourceDisplayName={getSourceDisplayName(sourceCode)}
fetchAndCacheSource={fetchAndCacheSource}
onSourceLoaded={handleSourceLoaded}
onUploadSource={uploadAndCacheSource}
/>
);
}
return (
<div className="p-4 text-sm text-muted-foreground">
No stat block available
</div>
);
};
const creatureName =
creature?.name ?? (bulkImportMode ? "Bulk Import" : "Creature");
if (isDesktop) {
return (
<div className="fixed top-0 right-0 bottom-0 flex w-[400px] flex-col border-l border-border bg-card">
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<span className="text-sm font-semibold text-muted-foreground">
Stat Block
</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>
<DesktopPanel
isFolded={isFolded}
side={side}
creatureName={creatureName}
panelRole={panelRole}
showPinButton={showPinButton}
onToggleFold={onToggleFold}
onPin={onPin}
onUnpin={onUnpin}
>
{renderContent()}
</DesktopPanel>
);
}
// Mobile drawer
return (
<div className="fixed inset-0 z-50">
{/* Backdrop */}
<button
type="button"
className="absolute inset-0 bg-black/50 animate-in fade-in"
onClick={onClose}
aria-label="Close stat block"
/>
{/* 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">
Stat Block
</span>
<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>
);
if (panelRole === "pinned") return null;
return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
}

View File

@@ -0,0 +1,49 @@
import { X } from "lucide-react";
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { Button } from "./ui/button.js";
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
variant="ghost"
size="icon-sm"
onClick={onDismiss}
className="text-muted-foreground"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>,
document.body,
);
}

View File

@@ -1,14 +1,13 @@
import type { Encounter } from "@initiative/domain";
import { StepBack, StepForward, Trash2 } from "lucide-react";
import { D20Icon } from "./d20-icon";
import { Button } from "./ui/button";
import { ConfirmButton } from "./ui/confirm-button";
interface TurnNavigationProps {
encounter: Encounter;
onAdvanceTurn: () => void;
onRetreatTurn: () => void;
onClearEncounter: () => void;
onRollAllInitiative: () => void;
}
export function TurnNavigation({
@@ -16,18 +15,16 @@ export function TurnNavigation({
onAdvanceTurn,
onRetreatTurn,
onClearEncounter,
onRollAllInitiative,
}: TurnNavigationProps) {
const hasCombatants = encounter.combatants.length > 0;
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
const activeCombatant = encounter.combatants[encounter.activeIndex];
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">
<Button
variant="outline"
size="icon"
className="h-8 w-8 text-foreground border-foreground hover:text-hover-action hover:border-hover-action hover:bg-transparent"
onClick={onRetreatTurn}
disabled={!hasCombatants || isAtStart}
title="Previous turn"
@@ -36,46 +33,28 @@ export function TurnNavigation({
<StepBack className="h-5 w-5" />
</Button>
<div className="text-center text-sm">
<div className="min-w-0 flex-1 flex items-center justify-center gap-2 text-sm">
<span className="rounded-full bg-muted text-foreground text-sm px-2 py-0.5 font-semibold shrink-0">
R{encounter.roundNumber}
</span>
{activeCombatant ? (
<>
<span className="font-medium">Round {encounter.roundNumber}</span>
<span className="text-muted-foreground">
{" "}
{activeCombatant.name}
</span>
</>
<span className="truncate font-medium">{activeCombatant.name}</span>
) : (
<span className="text-muted-foreground">No combatants</span>
)}
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-0">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-hover-action"
onClick={onRollAllInitiative}
title="Roll all initiative"
aria-label="Roll all initiative"
>
<D20Icon className="h-6 w-6" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-hover-destructive"
onClick={onClearEncounter}
disabled={!hasCombatants}
>
<Trash2 className="h-5 w-5" />
</Button>
</div>
<div className="flex flex-shrink-0 items-center gap-3">
<ConfirmButton
icon={<Trash2 className="h-5 w-5" />}
label="Clear encounter"
onConfirm={onClearEncounter}
disabled={!hasCombatants}
className="text-muted-foreground"
/>
<Button
variant="outline"
size="icon"
className="h-8 w-8 text-foreground border-foreground hover:text-hover-action hover:border-hover-action hover:bg-transparent"
onClick={onAdvanceTurn}
disabled={!hasCombatants}
title="Next turn"

View File

@@ -13,9 +13,9 @@ const buttonVariants = cva(
ghost: "hover:bg-hover-neutral-bg hover:text-hover-neutral",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 px-3 text-xs",
default: "h-8 px-3 text-xs",
icon: "h-8 w-8",
"icon-sm": "h-6 w-6",
},
},
defaultVariants: {

View File

@@ -0,0 +1,117 @@
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 size?: "icon" | "icon-sm";
readonly className?: string;
readonly disabled?: boolean;
}
const REVERT_TIMEOUT_MS = 5_000;
export function ConfirmButton({
onConfirm,
icon,
label,
size = "icon",
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={size}
className={cn(
className,
isConfirming
? "bg-destructive text-primary-foreground rounded-md animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground"
: "hover:text-hover-destructive",
)}
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

@@ -0,0 +1,72 @@
import { EllipsisVertical } from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { Button } from "./button";
export interface OverflowMenuItem {
readonly icon: ReactNode;
readonly label: string;
readonly onClick: () => void;
readonly disabled?: boolean;
}
interface OverflowMenuProps {
readonly items: readonly OverflowMenuItem[];
}
export function OverflowMenu({ items }: OverflowMenuProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleMouseDown(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
return (
<div ref={ref} className="relative">
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-hover-neutral"
onClick={() => setOpen((o) => !o)}
aria-label="More actions"
title="More actions"
>
<EllipsisVertical className="h-5 w-5" />
</Button>
{open && (
<div className="absolute bottom-full right-0 z-50 mb-1 min-w-48 rounded-md border border-border bg-card py-1 shadow-lg">
{items.map((item) => (
<button
key={item.label}
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-hover-neutral-bg disabled:pointer-events-none disabled:opacity-50"
disabled={item.disabled}
onClick={() => {
item.onClick();
setOpen(false);
}}
>
{item.icon}
{item.label}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -1,62 +1,126 @@
import type { Creature, CreatureId } from "@initiative/domain";
import { useEffect, useMemo, useRef, useState } from "react";
import { normalizeBestiary } from "../adapters/bestiary-adapter.js";
import type {
BestiaryIndexEntry,
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 {
search: (query: string) => Creature[];
search: (query: string) => SearchResult[];
getCreature: (id: CreatureId) => Creature | undefined;
allCreatures: Creature[];
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 {
const [creatures, setCreatures] = useState<Creature[]>([]);
const [isLoaded, setIsLoaded] = useState(false);
const creatureMapRef = useRef<Map<string, Creature>>(new Map());
const loadAttempted = useRef(false);
const creatureMapRef = useRef<Map<CreatureId, Creature>>(new Map());
const [, setTick] = useState(0);
useEffect(() => {
if (loadAttempted.current) return;
loadAttempted.current = true;
const index = loadBestiaryIndex();
setSourceDisplayNames(index.sources as Record<string, string>);
if (index.creatures.length > 0) {
setIsLoaded(true);
}
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);
} catch {
// Normalization failed — bestiary unavailable
}
})
.catch(() => {
// Import failed — bestiary unavailable
});
bestiaryCache.loadAllCachedCreatures().then((map) => {
creatureMapRef.current = map;
setTick((t) => t + 1);
});
}, []);
const search = useMemo(() => {
return (query: string): Creature[] => {
if (query.length < 2) return [];
const lower = query.toLowerCase();
return creatures
.filter((c) => c.name.toLowerCase().includes(lower))
.sort((a, b) => a.name.localeCompare(b.name))
.slice(0, 10);
};
}, [creatures]);
const getCreature = useMemo(() => {
return (id: CreatureId): Creature | undefined => {
return creatureMapRef.current.get(id);
};
const search = useCallback((query: string): SearchResult[] => {
if (query.length < 2) return [];
const lower = query.toLowerCase();
const index = loadBestiaryIndex();
return index.creatures
.filter((c) => c.name.toLowerCase().includes(lower))
.sort((a, b) => a.name.localeCompare(b.name))
.slice(0, 10)
.map((c) => ({
...c,
sourceDisplayName: getSourceDisplayName(c.source),
}));
}, []);
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,16 +14,17 @@ import {
toggleConditionUseCase,
} from "@initiative/application";
import type {
BestiaryIndexEntry,
CombatantId,
ConditionId,
Creature,
DomainEvent,
Encounter,
PlayerCharacter,
} from "@initiative/domain";
import {
combatantId,
createEncounter,
isDomainError,
creatureId as makeCreatureId,
resolveCreatureName,
} from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react";
@@ -32,24 +33,16 @@ import {
saveEncounter,
} from "../persistence/encounter-storage.js";
function createDemoEncounter(): Encounter {
const result = createEncounter([
{ id: combatantId("1"), name: "Aria" },
{ id: combatantId("2"), name: "Brak" },
{ id: combatantId("3"), name: "Cael" },
]);
if (isDomainError(result)) {
throw new Error(`Failed to create demo encounter: ${result.message}`);
}
return result;
}
const EMPTY_ENCOUNTER: Encounter = {
combatants: [],
activeIndex: 0,
roundNumber: 1,
};
function initializeEncounter(): Encounter {
const stored = loadEncounter();
if (stored !== null) return stored;
return createDemoEncounter();
return EMPTY_ENCOUNTER;
}
function deriveNextId(encounter: Encounter): number {
@@ -64,6 +57,33 @@ function deriveNextId(encounter: Encounter): number {
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() {
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
const [events, setEvents] = useState<DomainEvent[]>([]);
@@ -107,7 +127,7 @@ export function useEncounter() {
const nextId = useRef(deriveNextId(encounter));
const addCombatant = useCallback(
(name: string) => {
(name: string, opts?: CombatantOpts) => {
const id = combatantId(`c-${++nextId.current}`);
const result = addCombatantUseCase(makeStore(), id, name);
@@ -115,6 +135,13 @@ export function useEncounter() {
return;
}
if (opts) {
const optEvents = applyCombatantOpts(makeStore, id, opts);
if (optEvents.length > 0) {
setEvents((prev) => [...prev, ...optEvents]);
}
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
@@ -225,10 +252,6 @@ export function useEncounter() {
);
const clearEncounter = useCallback(() => {
if (!window.confirm("Clear the entire encounter? This cannot be undone.")) {
return;
}
const result = clearEncounterUseCase(makeStore());
if (isDomainError(result)) {
@@ -240,11 +263,11 @@ export function useEncounter() {
}, [makeStore]);
const addFromBestiary = useCallback(
(creature: Creature) => {
(entry: BestiaryIndexEntry) => {
const store = makeStore();
const existingNames = store.get().combatants.map((c) => c.name);
const { newName, renames } = resolveCreatureName(
creature.name,
entry.name,
existingNames,
);
@@ -262,28 +285,86 @@ export function useEncounter() {
if (isDomainError(addResult)) return;
// Set HP
const hpResult = setHpUseCase(makeStore(), id, creature.hp.average);
const hpResult = setHpUseCase(makeStore(), id, entry.hp);
if (!isDomainError(hpResult)) {
setEvents((prev) => [...prev, ...hpResult]);
}
// Set AC
if (creature.ac > 0) {
const acResult = setAcUseCase(makeStore(), id, creature.ac);
if (entry.ac > 0) {
const acResult = setAcUseCase(makeStore(), id, entry.ac);
if (!isDomainError(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 updated = {
store.save({
...currentEncounter,
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]);
},
[makeStore, editCombatant],
);
const addFromPlayerCharacter = useCallback(
(pc: PlayerCharacter) => {
const store = makeStore();
const existingNames = store.get().combatants.map((c) => c.name);
const { newName, renames } = resolveCreatureName(pc.name, existingNames);
for (const { from, to } of renames) {
const target = store.get().combatants.find((c) => c.name === from);
if (target) {
editCombatantUseCase(makeStore(), target.id, to);
}
}
const id = combatantId(`c-${++nextId.current}`);
const addResult = addCombatantUseCase(makeStore(), id, newName);
if (isDomainError(addResult)) return;
// Set HP
const hpResult = setHpUseCase(makeStore(), id, pc.maxHp);
if (!isDomainError(hpResult)) {
setEvents((prev) => [...prev, ...hpResult]);
}
// Set AC
if (pc.ac > 0) {
const acResult = setAcUseCase(makeStore(), id, pc.ac);
if (!isDomainError(acResult)) {
setEvents((prev) => [...prev, ...acResult]);
}
}
// Set color, icon, and playerCharacterId on the combatant
const currentEncounter = store.get();
store.save({
...currentEncounter,
combatants: currentEncounter.combatants.map((c) =>
c.id === id
? {
...c,
color: pc.color,
icon: pc.icon,
playerCharacterId: pc.id,
}
: c,
),
});
setEvents((prev) => [...prev, ...addResult]);
},
@@ -306,6 +387,7 @@ export function useEncounter() {
toggleCondition,
toggleConcentration,
addFromBestiary,
addFromPlayerCharacter,
makeStore,
} as const;
}

View File

@@ -0,0 +1,102 @@
import type { PlayerCharacterStore } from "@initiative/application";
import {
createPlayerCharacterUseCase,
deletePlayerCharacterUseCase,
editPlayerCharacterUseCase,
} from "@initiative/application";
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
import { isDomainError, playerCharacterId } from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react";
import {
loadPlayerCharacters,
savePlayerCharacters,
} from "../persistence/player-character-storage.js";
function initializeCharacters(): PlayerCharacter[] {
return loadPlayerCharacters();
}
let nextPcId = 0;
function generatePcId(): PlayerCharacterId {
return playerCharacterId(`pc-${++nextPcId}`);
}
interface EditFields {
readonly name?: string;
readonly ac?: number;
readonly maxHp?: number;
readonly color?: string;
readonly icon?: string;
}
export function usePlayerCharacters() {
const [characters, setCharacters] =
useState<PlayerCharacter[]>(initializeCharacters);
const charactersRef = useRef(characters);
charactersRef.current = characters;
useEffect(() => {
savePlayerCharacters(characters);
}, [characters]);
const makeStore = useCallback((): PlayerCharacterStore => {
return {
getAll: () => charactersRef.current,
save: (updated) => {
charactersRef.current = updated;
setCharacters(updated);
},
};
}, []);
const createCharacter = useCallback(
(name: string, ac: number, maxHp: number, color: string, icon: string) => {
const id = generatePcId();
const result = createPlayerCharacterUseCase(
makeStore(),
id,
name,
ac,
maxHp,
color,
icon,
);
if (isDomainError(result)) {
return result;
}
return undefined;
},
[makeStore],
);
const editCharacter = useCallback(
(id: PlayerCharacterId, fields: EditFields) => {
const result = editPlayerCharacterUseCase(makeStore(), id, fields);
if (isDomainError(result)) {
return result;
}
return undefined;
},
[makeStore],
);
const deleteCharacter = useCallback(
(id: PlayerCharacterId) => {
const result = deletePlayerCharacterUseCase(makeStore(), id);
if (isDomainError(result)) {
return result;
}
return undefined;
},
[makeStore],
);
return {
characters,
createCharacter,
editCharacter,
deleteCharacter,
makeStore,
} as const;
}

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

@@ -16,7 +16,7 @@
--color-hover-neutral: var(--color-primary);
--color-hover-action: var(--color-primary);
--color-hover-destructive: var(--color-destructive);
--color-hover-neutral-bg: var(--color-card);
--color-hover-neutral-bg: oklch(0.623 0.214 259 / 0.15);
--color-hover-action-bg: var(--color-muted);
--color-hover-destructive-bg: transparent;
--radius-sm: 0.25rem;
@@ -68,6 +68,101 @@
animation: slide-in-right 200ms ease-out;
}
@keyframes confirm-pulse {
0% {
scale: 1;
}
50% {
scale: 1.15;
}
100% {
scale: 1;
}
}
@keyframes settle-to-bottom {
from {
transform: translateY(-40vh);
opacity: 0;
}
40% {
opacity: 1;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@utility animate-settle-to-bottom {
animation: settle-to-bottom 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
@keyframes rise-to-center {
from {
transform: translateY(40vh);
opacity: 0;
}
40% {
opacity: 1;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@utility animate-rise-to-center {
animation: rise-to-center 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
@keyframes slide-down-in {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@utility animate-slide-down-in {
animation: slide-down-in 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
@keyframes slide-up-out {
from {
transform: translateY(0);
opacity: 1;
}
60% {
opacity: 0;
}
to {
transform: translateY(-100%);
opacity: 0;
}
}
@utility animate-slide-up-out {
animation: slide-up-out 700ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
@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 {
animation:
concentration-shake 450ms ease-out,

View File

@@ -0,0 +1,231 @@
import type { PlayerCharacter } from "@initiative/domain";
import { playerCharacterId } from "@initiative/domain";
import { beforeEach, describe, expect, it } from "vitest";
import {
loadPlayerCharacters,
savePlayerCharacters,
} from "../player-character-storage.js";
const STORAGE_KEY = "initiative:player-characters";
function createMockLocalStorage() {
const store = new Map<string, string>();
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, value),
removeItem: (key: string) => store.delete(key),
clear: () => store.clear(),
get length() {
return store.size;
},
key: (_index: number) => null,
store,
};
}
function makePC(overrides?: Partial<PlayerCharacter>): PlayerCharacter {
return {
id: playerCharacterId("pc-1"),
name: "Aragorn",
ac: 16,
maxHp: 120,
color: "green",
icon: "sword",
...overrides,
};
}
describe("player-character-storage", () => {
let mockStorage: ReturnType<typeof createMockLocalStorage>;
beforeEach(() => {
mockStorage = createMockLocalStorage();
Object.defineProperty(globalThis, "localStorage", {
value: mockStorage,
writable: true,
});
});
describe("round-trip save/load", () => {
it("saves and loads a single character", () => {
const pc = makePC();
savePlayerCharacters([pc]);
const loaded = loadPlayerCharacters();
expect(loaded).toEqual([pc]);
});
it("saves and loads multiple characters", () => {
const pcs = [
makePC({ id: playerCharacterId("pc-1"), name: "Aragorn" }),
makePC({
id: playerCharacterId("pc-2"),
name: "Legolas",
ac: 14,
maxHp: 90,
color: "blue",
icon: "eye",
}),
];
savePlayerCharacters(pcs);
const loaded = loadPlayerCharacters();
expect(loaded).toEqual(pcs);
});
});
describe("empty storage", () => {
it("returns empty array when no data exists", () => {
expect(loadPlayerCharacters()).toEqual([]);
});
});
describe("corrupt JSON", () => {
it("returns empty array for invalid JSON", () => {
mockStorage.setItem(STORAGE_KEY, "not-json{{{");
expect(loadPlayerCharacters()).toEqual([]);
});
it("returns empty array for non-array JSON", () => {
mockStorage.setItem(STORAGE_KEY, '{"foo": "bar"}');
expect(loadPlayerCharacters()).toEqual([]);
});
});
describe("per-character validation", () => {
it("discards character with missing name", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{ id: "pc-1", ac: 10, maxHp: 50, color: "blue", icon: "sword" },
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("discards character with empty name", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{
id: "pc-1",
name: "",
ac: 10,
maxHp: 50,
color: "blue",
icon: "sword",
},
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("discards character with invalid color", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{
id: "pc-1",
name: "Test",
ac: 10,
maxHp: 50,
color: "neon",
icon: "sword",
},
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("discards character with invalid icon", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{
id: "pc-1",
name: "Test",
ac: 10,
maxHp: 50,
color: "blue",
icon: "banana",
},
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("discards character with negative AC", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{
id: "pc-1",
name: "Test",
ac: -1,
maxHp: 50,
color: "blue",
icon: "sword",
},
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("discards character with maxHp of 0", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{
id: "pc-1",
name: "Test",
ac: 10,
maxHp: 0,
color: "blue",
icon: "sword",
},
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("keeps valid characters and discards invalid ones", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{
id: "pc-1",
name: "Valid",
ac: 10,
maxHp: 50,
color: "blue",
icon: "sword",
},
{
id: "pc-2",
name: "",
ac: 10,
maxHp: 50,
color: "blue",
icon: "sword",
},
]),
);
const loaded = loadPlayerCharacters();
expect(loaded).toHaveLength(1);
expect(loaded[0].name).toBe("Valid");
});
});
describe("storage errors", () => {
it("save silently catches errors", () => {
Object.defineProperty(globalThis, "localStorage", {
value: {
setItem: () => {
throw new Error("QuotaExceeded");
},
getItem: () => null,
},
writable: true,
});
expect(() => savePlayerCharacters([makePC()])).not.toThrow();
});
});
});

View File

@@ -5,7 +5,10 @@ import {
creatureId,
type Encounter,
isDomainError,
playerCharacterId,
VALID_CONDITION_IDS,
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
} from "@initiative/domain";
const STORAGE_KEY = "initiative:encounter";
@@ -18,6 +21,93 @@ 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 color =
typeof entry.color === "string" && VALID_PLAYER_COLORS.has(entry.color)
? entry.color
: undefined;
const icon =
typeof entry.icon === "string" && VALID_PLAYER_ICONS.has(entry.icon)
? entry.icon
: undefined;
const pcId =
typeof entry.playerCharacterId === "string" &&
entry.playerCharacterId.length > 0
? playerCharacterId(entry.playerCharacterId)
: undefined;
const shared = {
...base,
ac: validateAc(entry.ac),
conditions: validateConditions(entry.conditions),
isConcentrating: entry.isConcentrating === true ? true : undefined,
creatureId: validateCreatureId(entry.creatureId),
color,
icon,
playerCharacterId: pcId,
};
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 {
try {
const raw = localStorage.getItem(STORAGE_KEY);
@@ -45,82 +135,9 @@ export function loadEncounter(): Encounter | null {
};
}
for (const c of combatants) {
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;
}
if (!combatants.every(isValidCombatantEntry)) return null;
const rehydrated = combatants.map((c) => {
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 rehydrated = combatants.map(rehydrateCombatant);
const result = createEncounter(
rehydrated,

View File

@@ -0,0 +1,72 @@
import type { PlayerCharacter } from "@initiative/domain";
import {
playerCharacterId,
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
} from "@initiative/domain";
const STORAGE_KEY = "initiative:player-characters";
export function savePlayerCharacters(characters: PlayerCharacter[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(characters));
} catch {
// Silently swallow errors (quota exceeded, storage unavailable)
}
}
function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
return null;
const entry = raw as Record<string, unknown>;
if (typeof entry.id !== "string" || entry.id.length === 0) return null;
if (typeof entry.name !== "string" || entry.name.trim().length === 0)
return null;
if (
typeof entry.ac !== "number" ||
!Number.isInteger(entry.ac) ||
entry.ac < 0
)
return null;
if (
typeof entry.maxHp !== "number" ||
!Number.isInteger(entry.maxHp) ||
entry.maxHp < 1
)
return null;
if (typeof entry.color !== "string" || !VALID_PLAYER_COLORS.has(entry.color))
return null;
if (typeof entry.icon !== "string" || !VALID_PLAYER_ICONS.has(entry.icon))
return null;
return {
id: playerCharacterId(entry.id),
name: entry.name,
ac: entry.ac,
maxHp: entry.maxHp,
color: entry.color as PlayerCharacter["color"],
icon: entry.icon as PlayerCharacter["icon"],
};
}
export function loadPlayerCharacters(): PlayerCharacter[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
const characters: PlayerCharacter[] = [];
for (const item of parsed) {
const pc = rehydrateCharacter(item);
if (pc !== null) {
characters.push(pc);
}
}
return characters;
} catch {
return [];
}
}

View File

@@ -6,7 +6,9 @@
"!**/dist/**",
"!.claude/**",
"!.specify/**",
"!specs/**"
"!specs/**",
"!coverage/**",
"!.pnpm-store/**"
]
},
"assist": {
@@ -27,7 +29,15 @@
"linter": {
"enabled": true,
"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

@@ -0,0 +1,536 @@
---
date: "2026-03-13T14:58:42.882813+00:00"
git_commit: 75778884bd1be7d135b2f5ea9b8a8e77a0149f7b
branch: main
topic: "Declutter Action Bars"
tags: [plan, turn-navigation, action-bar, overflow-menu, ux]
status: draft
---
# Declutter Action Bars — Implementation Plan
## Overview
Reorganize buttons across the top bar (TurnNavigation) and bottom bar (ActionBar) to reduce visual clutter and improve UX. Each bar gets a clear purpose: the top bar is for turn navigation + encounter lifecycle, the bottom bar is for adding combatants + setup actions.
## Current State Analysis
**Top bar** (`turn-navigation.tsx`) has 5 buttons + center info:
```
[ Prev ] | [ R1 Dwarf ] | [ D20 Library Trash ] [ Next ]
```
The D20 (roll all initiative) and Library (manage sources) buttons are unrelated to turn navigation — they're setup/utility actions that add noise.
**Bottom bar** (`action-bar.tsx`) has an input, Add button, and 3 icon buttons:
```
[ + Add combatants... ] [ Add ] [ Users Eye Import ]
```
The icon cluster (Users, Eye, Import) is cryptic — three ghost icon buttons with no labels, requiring hover to discover purpose. The Eye button opens a separate search dropdown for browsing stat blocks, which duplicates the existing search input.
### Key Discoveries:
- `rollAllInitiativeUseCase` (`packages/application/src/roll-all-initiative-use-case.ts`) applies to combatants with `creatureId` AND no `initiative` set — this defines the conditional visibility logic
- `Combatant.initiative` is `number | undefined` and `Combatant.creatureId` is `CreatureId | undefined` (`packages/domain/src/types.ts`)
- No existing dropdown/menu UI component — the overflow menu needs a new component
- Lucide provides `EllipsisVertical` for the kebab menu trigger
- The stat block viewer already has its own search input, results list, and keyboard navigation (`action-bar.tsx:65-236`) — in browse mode, we reuse the main input for this instead
## Desired End State
### UI Mockups
**Top bar (after):**
```
[ Prev ] [ R1 Dwarf ] [ Trash ] [ Next ]
```
4 elements. Clean, focused on turn flow + encounter lifecycle.
**Bottom bar — add mode (default):**
```
[ + Add combatants... 👁 ] [ Add ] [ D20? ] [ ⋮ ]
```
The Eye icon sits inside/beside the input as a toggle. D20 appears conditionally. Kebab menu holds infrequent actions.
**Bottom bar — browse mode (Eye toggled on):**
```
[ 🔍 Search stat blocks... 👁 ] [ ⋮ ]
```
The input switches purpose: placeholder changes, typing searches stat blocks instead of adding combatants. The Add button and D20 hide (irrelevant in browse mode). Eye icon stays as the toggle to switch back. Selecting a result opens the stat block panel and exits browse mode.
**Overflow menu (⋮ clicked):**
```
┌──────────────────────┐
│ 👥 Player Characters │
│ 📚 Manage Sources │
│ 📥 Bulk Import │
└──────────────────────┘
```
Labeled items with icons — discoverable without hover.
### Key Discoveries:
- `sourceManagerOpen` state lives in App.tsx:116 — the overflow menu's "Manage Sources" item needs the same toggle callback
- The stat block viewer state (viewerOpen, viewerQuery, viewerResults, viewerIndex) in action-bar.tsx:66-71 gets replaced by a `browseMode` boolean that repurposes the main input
- The viewer's separate input, dropdown, and keyboard handling (action-bar.tsx:188-248) can be removed — browse mode reuses the existing input and suggestion dropdown infrastructure
## What We're NOT Doing
- Changing domain logic or use cases
- Modifying ConfirmButton behavior
- Changing the stat block panel itself
- Altering animation logic (useActionBarAnimation)
- Modifying combatant row buttons
- Changing how SourceManager works (just moving where the trigger lives)
## Implementation Approach
Four phases, each independently testable. Phase 1 simplifies the top bar (pure removal). Phase 2 adds the overflow menu component. Phase 3 reworks the ActionBar (browse toggle + conditional D20 + overflow integration). Phase 4 wires everything together in App.tsx.
---
## Phase 1: Simplify TurnNavigation
### Overview
Strip TurnNavigation down to just turn controls + clear encounter. Remove Roll All Initiative and Manage Sources buttons and their associated props.
### Changes Required:
#### [x] 1. Update TurnNavigation component
**File**: `apps/web/src/components/turn-navigation.tsx`
**Changes**:
- Remove `onRollAllInitiative` and `onOpenSourceManager` from props interface
- Remove the D20 button (lines 53-62)
- Remove the Library button (lines 63-72)
- Remove the inner `gap-0` div wrapper (lines 52, 80) since only the ConfirmButton remains
- Remove unused imports: `Library` from lucide-react, `D20Icon`
- Adjust layout: ConfirmButton + Next button grouped with `gap-3`
Result:
```tsx
interface TurnNavigationProps {
encounter: Encounter;
onAdvanceTurn: () => void;
onRetreatTurn: () => void;
onClearEncounter: () => void;
}
// Layout becomes:
// [ Prev ] | [ R1 Name ] | [ Trash ] [ Next ]
```
#### [x] 2. Update TurnNavigation usage in App.tsx
**File**: `apps/web/src/App.tsx`
**Changes**:
- Remove `onRollAllInitiative` and `onOpenSourceManager` props from the `<TurnNavigation>` call (lines 256-257)
### Success Criteria:
#### Automated Verification:
- [x] `pnpm check` passes (typecheck catches removed props, lint catches unused imports)
#### Manual Verification:
- [ ] Top bar shows only: Prev, round badge + name, trash, Next
- [ ] Prev/Next/Clear buttons still work as before
- [ ] Top bar animation (slide in/out) unchanged
**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: Create Overflow Menu Component
### Overview
Build a reusable overflow menu (kebab menu) component with click-outside and Escape handling, following the same patterns as ConfirmButton and the existing viewer dropdown.
### Changes Required:
#### [x] 1. Create OverflowMenu component
**File**: `apps/web/src/components/ui/overflow-menu.tsx` (new file)
**Changes**: Create a dropdown menu triggered by an EllipsisVertical icon button. Features:
- Toggle open/close on button click
- Close on click outside (document mousedown listener, same pattern as confirm-button.tsx:44-67)
- Close on Escape key
- Renders above the trigger (bottom-full positioning, same as action-bar suggestion dropdown)
- Each item: icon + label, full-width clickable row
- Clicking an item calls its action and closes the menu
```tsx
import { EllipsisVertical } from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { Button } from "./button";
export interface OverflowMenuItem {
readonly icon: ReactNode;
readonly label: string;
readonly onClick: () => void;
readonly disabled?: boolean;
}
interface OverflowMenuProps {
readonly items: readonly OverflowMenuItem[];
}
export function OverflowMenu({ items }: OverflowMenuProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleMouseDown(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
return (
<div ref={ref} className="relative">
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-hover-neutral"
onClick={() => setOpen((o) => !o)}
aria-label="More actions"
title="More actions"
>
<EllipsisVertical className="h-5 w-5" />
</Button>
{open && (
<div className="absolute bottom-full right-0 z-50 mb-1 min-w-48 rounded-md border border-border bg-card py-1 shadow-lg">
{items.map((item) => (
<button
key={item.label}
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-hover-neutral-bg disabled:pointer-events-none disabled:opacity-50"
disabled={item.disabled}
onClick={() => {
item.onClick();
setOpen(false);
}}
>
{item.icon}
{item.label}
</button>
))}
</div>
)}
</div>
);
}
```
### Success Criteria:
#### Automated Verification:
- [x] `pnpm check` passes (new file compiles, no unused exports yet — will be used in phase 3)
#### Manual Verification:
- [ ] N/A — component not yet wired into the UI
**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 3: Rework ActionBar
### Overview
Replace the icon button cluster with: (1) an Eye toggle on the input that switches between add mode and browse mode, (2) a conditional Roll All Initiative button, and (3) the overflow menu for infrequent actions.
### Changes Required:
#### [x] 1. Update ActionBarProps
**File**: `apps/web/src/components/action-bar.tsx`
**Changes**: Add new props, keep existing ones needed for overflow menu items:
```tsx
interface ActionBarProps {
// ... existing props stay ...
onRollAllInitiative?: () => void; // new — moved from top bar
showRollAllInitiative?: boolean; // new — conditional visibility
onOpenSourceManager?: () => void; // new — moved from top bar
}
```
#### [x] 2. Add browse mode state
**File**: `apps/web/src/components/action-bar.tsx`
**Changes**: Replace the separate viewer state (viewerOpen, viewerQuery, viewerResults, viewerIndex, viewerRef, viewerInputRef — lines 66-71) with a single `browseMode` boolean:
```tsx
const [browseMode, setBrowseMode] = useState(false);
```
Remove all viewer-specific state variables and handlers:
- `viewerOpen`, `viewerQuery`, `viewerResults`, `viewerIndex` (lines 66-69)
- `viewerRef`, `viewerInputRef` (lines 70-71)
- `openViewer`, `closeViewer` (lines 189-202)
- `handleViewerQueryChange`, `handleViewerSelect`, `handleViewerKeyDown` (lines 204-236)
- The viewer click-outside effect (lines 239-248)
#### [x] 3. Rework the input area with Eye toggle
**File**: `apps/web/src/components/action-bar.tsx`
**Changes**: Add an Eye icon button inside the input wrapper that toggles browse mode. When browse mode is active:
- Placeholder changes to "Search stat blocks..."
- Typing calls `bestiarySearch` but selecting a result calls `onViewStatBlock` instead of queuing/adding
- The suggestion dropdown shows results but clicking opens stat block panel instead of adding
- Add button and custom fields (Init/AC/MaxHP) are hidden
- D20 button is hidden
When toggling browse mode off, clear the input and suggestions.
The Eye icon sits to the right of the input inside the `relative flex-1` wrapper:
```tsx
<div className="relative flex-1">
<Input
ref={inputRef}
type="text"
value={nameInput}
onChange={(e) => handleNameChange(e.target.value)}
onKeyDown={browseMode ? handleBrowseKeyDown : handleKeyDown}
placeholder={browseMode ? "Search stat blocks..." : "+ Add combatants"}
className="max-w-xs pr-8"
autoFocus={autoFocus}
/>
{bestiaryLoaded && onViewStatBlock && (
<button
type="button"
className={cn(
"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
browseMode && "text-accent",
)}
onClick={() => {
setBrowseMode((m) => !m);
setNameInput("");
setSuggestions([]);
setPcMatches([]);
setQueued(null);
setSuggestionIndex(-1);
}}
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
aria-label={browseMode ? "Switch to add mode" : "Browse stat blocks"}
>
<Eye className="h-4 w-4" />
</button>
)}
{/* suggestion dropdown — behavior changes based on browseMode */}
</div>
```
Import `cn` from `../../lib/utils` (already used by other components).
#### [x] 4. Update suggestion dropdown for browse mode
**File**: `apps/web/src/components/action-bar.tsx`
**Changes**: In browse mode, the suggestion dropdown behaves differently:
- No "Add as custom" row at the top
- No player character matches section
- No queuing (plus/minus/confirm) — clicking a result calls `onViewStatBlock` and exits browse mode
- Keyboard Enter on a highlighted result calls `onViewStatBlock` and exits browse mode
Add a `handleBrowseKeyDown` handler:
```tsx
const handleBrowseKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
setBrowseMode(false);
setNameInput("");
setSuggestions([]);
setSuggestionIndex(-1);
return;
}
if (suggestions.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
} else if (e.key === "Enter" && suggestionIndex >= 0) {
e.preventDefault();
onViewStatBlock?.(suggestions[suggestionIndex]);
setBrowseMode(false);
setNameInput("");
setSuggestions([]);
setSuggestionIndex(-1);
}
};
```
In the suggestion dropdown JSX, conditionally render based on `browseMode`:
- Browse mode: simple list of creature results, click → `onViewStatBlock` + exit browse mode
- Add mode: existing behavior (custom row, PC matches, queuing)
#### [x] 5. Replace icon button cluster with D20 + overflow menu
**File**: `apps/web/src/components/action-bar.tsx`
**Changes**: Replace the `div.flex.items-center.gap-0` block (lines 443-529) containing Users, Eye, and Import buttons with:
```tsx
{!browseMode && (
<>
<Button type="submit" size="sm">
Add
</Button>
{showRollAllInitiative && onRollAllInitiative && (
<Button
type="button"
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-hover-action"
onClick={onRollAllInitiative}
title="Roll all initiative"
aria-label="Roll all initiative"
>
<D20Icon className="h-6 w-6" />
</Button>
)}
</>
)}
<OverflowMenu items={overflowItems} />
```
Build the `overflowItems` array from props:
```tsx
const overflowItems: OverflowMenuItem[] = [];
if (onManagePlayers) {
overflowItems.push({
icon: <Users className="h-4 w-4" />,
label: "Player Characters",
onClick: onManagePlayers,
});
}
if (onOpenSourceManager) {
overflowItems.push({
icon: <Library className="h-4 w-4" />,
label: "Manage Sources",
onClick: onOpenSourceManager,
});
}
if (bestiaryLoaded && onBulkImport) {
overflowItems.push({
icon: <Import className="h-4 w-4" />,
label: "Bulk Import",
onClick: onBulkImport,
disabled: bulkImportDisabled,
});
}
```
#### [x] 6. Clean up imports
**File**: `apps/web/src/components/action-bar.tsx`
**Changes**:
- Add imports: `D20Icon`, `OverflowMenu` + `OverflowMenuItem`, `Library` from lucide-react, `cn` from utils
- Remove imports that are no longer needed after removing the standalone viewer: check which of `Eye`, `Import`, `Users` are still used (Eye stays for the toggle, Users and Import stay for overflow item icons, Library is new)
- The `Check`, `Minus`, `Plus` imports stay (used in queuing UI)
### Success Criteria:
#### Automated Verification:
- [x] `pnpm check` passes
#### Manual Verification:
- [ ] Bottom bar shows: input with Eye toggle, Add button, (conditional D20), kebab menu
- [ ] Eye toggle switches input between "add" and "browse" modes
- [ ] In browse mode: typing shows bestiary results, clicking one opens stat block panel, exits browse mode
- [ ] In browse mode: Add button and D20 are hidden, overflow menu stays visible
- [ ] In add mode: existing behavior works (search, queue, custom fields, PC matches)
- [ ] Overflow menu opens/closes on click, closes on Escape and click-outside
- [ ] Overflow menu items (Player Characters, Manage Sources, Bulk Import) trigger correct actions
- [ ] D20 button appears only when bestiary combatants lack initiative, disappears when all have values
**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 4: Wire Up App.tsx
### Overview
Pass the new props to ActionBar — roll all initiative handler, conditional visibility flag, and source manager toggle. Remove the now-unused `onOpenSourceManager` callback from the TurnNavigation call (already removed in Phase 1) and ensure sourceManagerOpen toggle is routed through the overflow menu.
### Changes Required:
#### [x] 1. Compute showRollAllInitiative flag
**File**: `apps/web/src/App.tsx`
**Changes**: Add a derived boolean that checks if any combatant with a `creatureId` lacks an `initiative` value:
```tsx
const showRollAllInitiative = encounter.combatants.some(
(c) => c.creatureId != null && c.initiative == null,
);
```
Place this near `const isEmpty = ...` (line 241).
#### [x] 2. Pass new props to both ActionBar instances
**File**: `apps/web/src/App.tsx`
**Changes**: Add to both `<ActionBar>` calls (empty state at ~line 269 and populated state at ~line 328):
```tsx
<ActionBar
// ... existing props ...
onRollAllInitiative={handleRollAllInitiative}
showRollAllInitiative={showRollAllInitiative}
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
/>
```
#### [x] 3. Remove stale code
**File**: `apps/web/src/App.tsx`
**Changes**:
- The `onRollAllInitiative` and `onOpenSourceManager` props were already removed from `<TurnNavigation>` in Phase 1 — verify no references remain
- Verify `sourceManagerOpen` state and the `<SourceManager>` rendering block (lines 287-291) still work correctly — the SourceManager inline panel is still toggled by the same state, just from a different trigger location
### Success Criteria:
#### Automated Verification:
- [x] `pnpm check` passes
#### Manual Verification:
- [ ] Top bar: only Prev, round badge + name, trash, Next — no D20 or Library buttons
- [ ] Bottom bar: input with Eye toggle, Add, conditional D20, overflow menu
- [ ] Roll All Initiative (D20 in bottom bar): visible when bestiary creatures lack initiative, hidden after rolling
- [ ] Overflow → Player Characters: opens player management modal
- [ ] Overflow → Manage Sources: toggles source manager panel (same as before, just different trigger)
- [ ] Overflow → Bulk Import: opens bulk import mode
- [ ] Browse mode (Eye toggle): search stat blocks without adding, selecting opens panel
- [ ] Clear encounter (top bar trash): still works with two-click confirmation
- [ ] All animations (bar transitions) unchanged
- [ ] Empty state: ActionBar centered with all functionality accessible
**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.
---
## Testing Strategy
### Unit Tests:
- No domain/application changes — existing tests should pass unchanged
- `pnpm check` covers typecheck + lint + existing test suite
### Manual Testing Steps:
1. Start with empty encounter — verify ActionBar is centered with Eye toggle and overflow menu
2. Add a bestiary creature — verify D20 appears in bottom bar, top bar slides in with just 4 elements
3. Click D20 → initiative rolls → D20 disappears from bottom bar
4. Toggle Eye → input switches to browse mode → search and select → stat block opens → exits browse mode
5. Open overflow menu → click each item → verify correct modal/panel opens
6. Click trash in top bar → confirm → encounter clears, back to empty state
7. Add custom creature (no creatureId) → D20 should not appear (no bestiary creatures)
8. Add mix of custom + bestiary creatures → D20 visible → roll all → D20 hidden
## Performance Considerations
None — this is a pure UI reorganization with no new data fetching, state management changes, or rendering overhead. The `showRollAllInitiative` computation is a simple `.some()` over the combatant array, which is negligible.
## References
- Research: `docs/agents/research/2026-03-13-action-bars-and-buttons.md`
- Top bar: `apps/web/src/components/turn-navigation.tsx`
- Bottom bar: `apps/web/src/components/action-bar.tsx`
- App layout: `apps/web/src/App.tsx`
- Button: `apps/web/src/components/ui/button.tsx`
- ConfirmButton: `apps/web/src/components/ui/confirm-button.tsx`
- Roll all use case: `packages/application/src/roll-all-initiative-use-case.ts`
- Combatant type: `packages/domain/src/types.ts`

View File

View File

@@ -0,0 +1,176 @@
---
date: "2026-03-13T14:39:15.661886+00:00"
git_commit: 75778884bd1be7d135b2f5ea9b8a8e77a0149f7b
branch: main
topic: "Action Bars Setup — Top Bar and Bottom Bar Buttons"
tags: [research, codebase, action-bar, turn-navigation, layout, buttons]
status: complete
---
# Research: Action Bars Setup — Top Bar and Bottom Bar Buttons
## Research Question
How are the top and bottom action bars set up, what buttons do they contain, and how are their actions wired?
## Summary
The application has two primary bar components that frame the encounter tracker UI:
1. **Top bar**`TurnNavigation` (`turn-navigation.tsx`) — turn controls, round/combatant display, and encounter-wide actions.
2. **Bottom bar**`ActionBar` (`action-bar.tsx`) — combatant input, bestiary search, stat block browsing, bulk import, and player character management.
Both bars share the same visual container styling (`flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3`). They are laid out in `App.tsx` within a flex column, with a scrollable combatant list between them. When the encounter is empty, only the ActionBar is shown (centered in the viewport); the TurnNavigation appears with an animation when the first combatant is added.
## Detailed Findings
### Layout Structure (`App.tsx:243-344`)
The bars live inside a `max-w-2xl` centered column:
```
┌──────────────────────────────────┐
│ TurnNavigation (pt-8, shrink-0) │ ← top bar, conditionally shown
├──────────────────────────────────┤
│ SourceManager (optional inline) │ ← toggled by Library button in top bar
├──────────────────────────────────┤
│ Combatant list (flex-1, │ ← scrollable
│ overflow-y-auto) │
├──────────────────────────────────┤
│ ActionBar (pb-8, shrink-0) │ ← bottom bar
└──────────────────────────────────┘
```
**Empty state**: When `encounter.combatants.length === 0`, the top bar is hidden and the ActionBar is vertically centered in a `flex items-center justify-center` wrapper with `pb-[15%]` offset. It receives `autoFocus` in this state.
**Animation** (`useActionBarAnimation`, `App.tsx:30-66`): Manages transitions between empty and populated states:
- Empty → populated: ActionBar plays `animate-settle-to-bottom`, TurnNavigation plays `animate-slide-down-in`.
- Populated → empty: ActionBar plays `animate-rise-to-center`, TurnNavigation plays `animate-slide-up-out` (with `absolute` positioning during exit).
The `showTopBar` flag is `true` when either combatants exist or the top bar exit animation is still running.
### Top Bar — TurnNavigation (`turn-navigation.tsx`)
**Props interface** (`turn-navigation.tsx:7-14`):
- `encounter: Encounter` — full encounter state
- `onAdvanceTurn`, `onRetreatTurn` — turn navigation callbacks
- `onClearEncounter` — destructive clear with confirmation
- `onRollAllInitiative` — rolls initiative for all combatants
- `onOpenSourceManager` — toggles source manager panel
**Layout**: LeftCenterRight structure:
```
[ ◀ Prev ] | [ R1 Active Combatant Name ] | [ 🎲 📚 🗑 ] [ Next ▶ ]
```
**Buttons (left to right)**:
| # | Icon | Component | Variant | Action | Disabled when |
|---|------|-----------|---------|--------|---------------|
| 1 | `StepBack` | `Button` | default | `onRetreatTurn` | No combatants OR at round 1 index 0 |
| 2 | `D20Icon` | `Button` | ghost | `onRollAllInitiative` | Never |
| 3 | `Library` | `Button` | ghost | `onOpenSourceManager` | Never |
| 4 | `Trash2` | `ConfirmButton` | — | `onClearEncounter` | No combatants |
| 5 | `StepForward` | `Button` | default | `onAdvanceTurn` | No combatants |
**Center section** (`turn-navigation.tsx:40-49`): Displays a round badge (`R{n}` in a `rounded-full bg-muted` span) and the active combatant's name (truncated). Falls back to "No combatants" in muted text.
**Button grouping**: Buttons 2-4 are grouped in a `gap-0` div (tight spacing), while button 5 (Next) is separated by the outer `gap-3`.
**Wiring in App.tsx** (`App.tsx:251-258`):
- `onAdvanceTurn``advanceTurn` from `useEncounter()`
- `onRetreatTurn``retreatTurn` from `useEncounter()`
- `onClearEncounter``clearEncounter` from `useEncounter()`
- `onRollAllInitiative``handleRollAllInitiative` → calls `rollAllInitiativeUseCase(makeStore(), rollDice, getCreature)`
- `onOpenSourceManager` → toggles `sourceManagerOpen` state
### Bottom Bar — ActionBar (`action-bar.tsx`)
**Props interface** (`action-bar.tsx:20-36`):
- `onAddCombatant` — adds custom combatant with optional init/AC/maxHP
- `onAddFromBestiary` — adds creature from search result
- `bestiarySearch` — search function returning `SearchResult[]`
- `bestiaryLoaded` — whether bestiary index is loaded
- `onViewStatBlock` — opens stat block panel for a creature
- `onBulkImport` — triggers bulk source import mode
- `bulkImportDisabled` — disables import button during loading
- `inputRef` — external ref to the name input
- `playerCharacters` — list of player characters for quick-add
- `onAddFromPlayerCharacter` — adds a player character to encounter
- `onManagePlayers` — opens player management modal
- `autoFocus` — auto-focuses input (used in empty state)
**Layout**: Form with input, contextual fields, submit button, and action icons:
```
[ + Add combatants... ] [ Init ] [ AC ] [ MaxHP ] [ Add ] [ 👥 👁 📥 ]
```
The Init/AC/MaxHP fields only appear when the input has 2+ characters and no bestiary suggestions are showing.
**Buttons (left to right)**:
| # | Icon | Component | Variant | Action | Condition |
|---|------|-----------|---------|--------|-----------|
| 1 | — | `Button` | sm | Form submit → `handleAdd` | Always shown |
| 2 | `Users` | `Button` | ghost | `onManagePlayers` | Only if `onManagePlayers` provided |
| 3 | `Eye` | `Button` | ghost | Toggle stat block viewer dropdown | Only if `bestiaryLoaded && onViewStatBlock` |
| 4 | `Import` | `Button` | ghost | `onBulkImport` | Only if `bestiaryLoaded && onBulkImport` |
**Button grouping**: Buttons 2-4 are grouped in a `gap-0` div, mirroring the top bar's icon button grouping.
**Suggestion dropdown** (`action-bar.tsx:267-410`): Opens above the input when 2+ chars are typed and results exist. Contains:
- A "Add as custom" escape row at the top (with `Esc` keyboard hint)
- **Players section**: Lists matching player characters with colored icons; clicking adds them directly via `onAddFromPlayerCharacter`
- **Bestiary section**: Lists search results; clicking queues a creature. Queued creatures show:
- `Minus` button — decrements count (removes queue at 0)
- Count badge — current queued count
- `Plus` button — increments count
- `Check` button — confirms and adds all queued copies
**Stat block viewer dropdown** (`action-bar.tsx:470-513`): A separate search dropdown anchored to the Eye button. Has its own input, search results, and keyboard navigation. Selecting a result calls `onViewStatBlock`.
**Keyboard handling** (`action-bar.tsx:168-186`):
- Arrow Up/Down — navigate suggestion list
- Enter — queue selected suggestion or confirm queued batch
- Escape — clear suggestions and queue
**Wiring in App.tsx** (`App.tsx:269-282` and `328-340`):
- `onAddCombatant``addCombatant` from `useEncounter()`
- `onAddFromBestiary``handleAddFromBestiary``addFromBestiary` from `useEncounter()`
- `bestiarySearch``search` from `useBestiary()`
- `onViewStatBlock``handleViewStatBlock` → constructs `CreatureId` and sets `selectedCreatureId`
- `onBulkImport``handleBulkImport` → sets `bulkImportMode` and clears selection
- `onAddFromPlayerCharacter``addFromPlayerCharacter` from `useEncounter()`
- `onManagePlayers` → opens `managementOpen` state (shows `PlayerManagement` modal)
### Shared UI Primitives
**`Button`** (`ui/button.tsx`): CVA-based component with variants (`default`, `outline`, `ghost`) and sizes (`default`, `sm`, `icon`). Both bars use `size="icon"` with `variant="ghost"` for their icon button clusters, and `size="icon"` with default variant for the primary navigation buttons (Prev/Next in top bar).
**`ConfirmButton`** (`ui/confirm-button.tsx`): Two-click destructive action button. First click shows a red pulsing confirmation state with a Check icon; second click fires `onConfirm`. Auto-reverts after 5 seconds. Supports Escape and click-outside cancellation. Used for Clear Encounter in the top bar.
### Hover Color Convention
Both bars use consistent hover color classes on their ghost icon buttons:
- `hover:text-hover-action` — used on the D20 (roll initiative) button, suggesting an action/accent color
- `hover:text-hover-neutral` — used on Library, Users, Eye, Import buttons, suggesting a neutral/informational color
## Code References
- `apps/web/src/components/turn-navigation.tsx` — Top bar component (93 lines)
- `apps/web/src/components/action-bar.tsx` — Bottom bar component (533 lines)
- `apps/web/src/App.tsx:30-66``useActionBarAnimation` hook for bar transitions
- `apps/web/src/App.tsx:243-344` — Layout structure with both bars
- `apps/web/src/components/ui/button.tsx` — Shared Button component
- `apps/web/src/components/ui/confirm-button.tsx` — Two-step confirmation button
- `apps/web/src/components/d20-icon.tsx` — Custom D20 dice SVG icon
## Architecture Documentation
The bars follow the app's adapter-layer convention: they are pure presentational React components that receive all behavior via callback props. No business logic lives in either bar — they delegate to handlers defined in `App.tsx`, which in turn call use-case functions from the application layer or manipulate local UI state.
Both bars are rendered twice in `App.tsx` (once in the empty-state branch, once in the populated branch) rather than being conditionally repositioned, which simplifies the animation logic.
The `ActionBar` is the more complex of the two, managing multiple pieces of local state (input value, suggestions, queued creatures, custom fields, stat block viewer) while `TurnNavigation` is fully stateless — all its data comes from the `encounter` prop.

View File

@@ -0,0 +1,188 @@
---
date: "2026-03-13T15:35:07.699570+00:00"
git_commit: bd398080008349b47726d0016f4b03587f453833
branch: main
topic: "CSS class usage, button categorization, and hover effects across all components"
tags: [research, codebase, css, tailwind, buttons, hover, ui]
status: complete
---
# Research: CSS Class Usage, Button Categorization, and Hover Effects
## Research Question
How are CSS classes used across all components? How are buttons categorized — are there primary and secondary buttons? What hover effects exist, and are they unified?
## Summary
The project uses **Tailwind CSS v4** with a custom dark theme defined in `index.css` via `@theme`. All class merging goes through a `cn()` utility (clsx + tailwind-merge). Buttons are built on a shared `Button` component using **class-variance-authority (CVA)** with three variants: **default** (primary), **outline**, and **ghost**. Hover effects are partially unified through semantic color tokens (`hover-neutral`, `hover-action`, `hover-destructive`) defined in the theme, but several components use **one-off hardcoded hover colors** that bypass the token system.
## Detailed Findings
### Theme System (`index.css`)
All colors are defined as CSS custom properties via Tailwind v4's `@theme` directive (`index.css:3-26`):
| Token | Value | Purpose |
|---|---|---|
| `--color-background` | `#0f172a` | Page background |
| `--color-foreground` | `#e2e8f0` | Default text |
| `--color-muted` | `#64748b` | Subdued elements |
| `--color-muted-foreground` | `#94a3b8` | Secondary text |
| `--color-card` | `#1e293b` | Card/panel surfaces |
| `--color-border` | `#334155` | Borders |
| `--color-primary` | `#3b82f6` | Primary actions (blue) |
| `--color-accent` | `#3b82f6` | Accent (same as primary) |
| `--color-destructive` | `#ef4444` | Destructive actions (red) |
**Hover tokens** (semantic layer for hover states):
| Token | Resolves to | Usage |
|---|---|---|
| `hover-neutral` | `primary` (blue) | Text color on neutral hover |
| `hover-action` | `primary` (blue) | Text color on action hover |
| `hover-destructive` | `destructive` (red) | Text color on destructive hover |
| `hover-neutral-bg` | `card` (slate) | Background on neutral hover |
| `hover-action-bg` | `muted` | Background on action hover |
| `hover-destructive-bg` | `transparent` | Background on destructive hover |
### Button Component (`components/ui/button.tsx`)
Uses CVA with three variants and three sizes:
**Variants:**
| Variant | Base styles | Hover |
|---|---|---|
| `default` (primary) | `bg-primary text-primary-foreground` | `hover:bg-primary/90` |
| `outline` | `border border-border bg-transparent` | `hover:bg-hover-neutral-bg hover:text-hover-neutral` |
| `ghost` | (no background/border) | `hover:bg-hover-neutral-bg hover:text-hover-neutral` |
**Sizes:**
| Size | Classes |
|---|---|
| `default` | `h-9 px-4 py-2` |
| `sm` | `h-8 px-3 text-xs` |
| `icon` | `h-8 w-8` |
All variants share: `rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50`.
There is **no "secondary" variant** — the outline variant is the closest equivalent.
### Composite Button Components
**ConfirmButton** (`components/ui/confirm-button.tsx`):
- Wraps `Button variant="ghost" size="icon"`
- Default state: `hover:text-hover-destructive` (uses token)
- Confirming state: `bg-destructive text-primary-foreground animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground`
**OverflowMenu** (`components/ui/overflow-menu.tsx`):
- Trigger: `Button variant="ghost" size="icon"` with `text-muted-foreground hover:text-hover-neutral`
- Menu items: raw `<button>` elements with `hover:bg-muted/20` (**not using the token system**)
### Button Usage Across Components
| Component | Button type | Variant/Style |
|---|---|---|
| `action-bar.tsx:556` | `<Button type="submit">` | default (primary) — "Add" |
| `action-bar.tsx:561` | `<Button type="button">` | default (primary) — "Roll all" |
| `turn-navigation.tsx:25,54` | `<Button size="icon">` | default — prev/next turn |
| `turn-navigation.tsx:47` | `<ConfirmButton>` | ghost+destructive — clear encounter |
| `source-fetch-prompt.tsx:91` | `<Button size="sm">` | default — "Load" |
| `source-fetch-prompt.tsx:106` | `<Button size="sm" variant="outline">` | outline — "Upload file" |
| `bulk-import-prompt.tsx:31,45,106` | `<Button size="sm">` | default — "Done"/"Load All" |
| `source-manager.tsx:50` | `<Button size="sm" variant="outline">` | outline — "Clear all" |
| `hp-adjust-popover.tsx:112` | `<Button variant="ghost" size="icon">` | ghost + custom red — damage |
| `hp-adjust-popover.tsx:124` | `<Button variant="ghost" size="icon">` | ghost + custom green — heal |
| `player-management.tsx:67` | `<Button>` | default — "Create first player" |
| `player-management.tsx:113` | `<Button variant="ghost">` | ghost — "Add player" |
| `create-player-modal.tsx:177` | `<Button variant="ghost">` | ghost — "Cancel" |
| `create-player-modal.tsx:180` | `<Button type="submit">` | default — "Save"/"Create" |
| `combatant-row.tsx:625` | `<ConfirmButton>` | ghost+destructive — remove combatant |
**Raw `<button>` elements** (not using the Button component):
- `action-bar.tsx` — suggestion items, count increment/decrement, browse toggle, custom add (all inline-styled)
- `combatant-row.tsx` — editable name, HP display, AC, initiative, concentration toggle
- `stat-block-panel.tsx` — fold/close/pin/unpin buttons
- `condition-picker.tsx` — condition items
- `condition-tags.tsx` — condition tags, add condition button
- `toast.tsx` — dismiss button
- `player-management.tsx` — close modal, edit player
- `create-player-modal.tsx` — close modal
- `color-palette.tsx` — color swatches
- `icon-grid.tsx` — icon options
### Hover Effects Inventory
**Using semantic tokens (unified):**
| Hover class | Meaning | Used in |
|---|---|---|
| `hover:bg-hover-neutral-bg` | Neutral background highlight | button.tsx (outline/ghost), action-bar.tsx, condition-picker.tsx, condition-tags.tsx |
| `hover:text-hover-neutral` | Text turns primary blue | button.tsx (outline/ghost), action-bar.tsx, combatant-row.tsx, stat-block-panel.tsx, ac-shield.tsx, toast.tsx, overflow-menu.tsx, condition-tags.tsx |
| `hover:text-hover-action` | Action text (same as neutral) | action-bar.tsx (overflow trigger) |
| `hover:text-hover-destructive` | Destructive text turns red | confirm-button.tsx, source-manager.tsx |
| `hover:bg-hover-destructive-bg` | Destructive background (transparent) | source-manager.tsx |
**One-off / hardcoded hover colors (NOT using tokens):**
| Hover class | Used in | Context |
|---|---|---|
| `hover:bg-primary/90` | button.tsx (default variant) | Primary button darken |
| `hover:bg-accent/20` | action-bar.tsx | Suggestion highlight, custom add |
| `hover:bg-accent/40` | action-bar.tsx | Count +/- buttons, confirm queued |
| `hover:bg-muted/20` | overflow-menu.tsx | Menu item highlight |
| `hover:bg-red-950` | hp-adjust-popover.tsx | Damage button |
| `hover:text-red-300` | hp-adjust-popover.tsx | Damage button text |
| `hover:bg-emerald-950` | hp-adjust-popover.tsx | Heal button |
| `hover:text-emerald-300` | hp-adjust-popover.tsx | Heal button text |
| `hover:text-foreground` | player-management.tsx, create-player-modal.tsx, icon-grid.tsx | Close/edit buttons |
| `hover:bg-background/50` | player-management.tsx | Player row hover |
| `hover:bg-card` | icon-grid.tsx | Icon option hover |
| `hover:border-hover-destructive` | source-manager.tsx | Clear all button border |
| `hover:scale-110` | color-palette.tsx | Color swatch enlarge |
| `hover:bg-destructive` | confirm-button.tsx (confirming state) | Maintain red bg on hover |
| `hover:text-primary-foreground` | confirm-button.tsx (confirming state) | Maintain white text on hover |
### Hover unification assessment
The hover token system (`hover-neutral`, `hover-action`, `hover-destructive`) provides a consistent pattern for the most common interactions. The `Button` component's outline and ghost variants use these tokens, and many inline buttons in action-bar, combatant-row, stat-block-panel, and condition components also use them.
However, there are notable gaps:
1. **HP adjust popover** uses hardcoded red/green colors (`red-950`, `emerald-950`) instead of tokens
2. **Overflow menu items** use `hover:bg-muted/20` instead of `hover:bg-hover-neutral-bg`
3. **Player management modals** use `hover:text-foreground` and `hover:bg-background/50` instead of the semantic tokens
4. **Action-bar suggestion items** use `hover:bg-accent/20` and `hover:bg-accent/40` — accent-specific patterns not in the token system
5. **Icon grid** and **color palette** use their own hover patterns (`hover:bg-card`, `hover:scale-110`)
## Code References
- `apps/web/src/index.css:3-26` — Theme color definitions including hover tokens
- `apps/web/src/components/ui/button.tsx:1-38` — Button component with CVA variants
- `apps/web/src/components/ui/confirm-button.tsx:93-115` — ConfirmButton with destructive hover states
- `apps/web/src/components/ui/overflow-menu.tsx:38-72` — OverflowMenu with non-token hover
- `apps/web/src/components/hp-adjust-popover.tsx:117-129` — Hardcoded red/green hover colors
- `apps/web/src/components/action-bar.tsx:80-188` — Mixed token and accent-based hovers
- `apps/web/src/components/combatant-row.tsx:147-629` — Inline buttons with token hovers
- `apps/web/src/components/player-management.tsx:58-98` — Non-token hover patterns
- `apps/web/src/components/stat-block-panel.tsx:55-109` — Consistent token usage
- `apps/web/src/lib/utils.ts:1-5``cn()` utility (clsx + twMerge)
## Architecture Documentation
The styling architecture follows this pattern:
1. **Theme layer**: `index.css` defines all color tokens via `@theme`, including semantic hover tokens
2. **Component layer**: `Button` (CVA) provides the shared button abstraction with three variants
3. **Composite layer**: `ConfirmButton` and `OverflowMenu` wrap `Button` with additional behavior
4. **Usage layer**: Components use either `Button` component or raw `<button>` elements with inline Tailwind classes
The `cn()` utility from `lib/utils.ts` is used in 9+ components for conditional class merging.
Custom animations are defined in `index.css` via `@keyframes` + `@utility` pairs: slide-in-right, confirm-pulse, settle-to-bottom, rise-to-center, slide-down-in, slide-up-out, concentration-pulse.
## Open Questions
1. The `hover-action` and `hover-action-bg` tokens are defined but rarely used — `hover-action` appears only once in `action-bar.tsx:565`. Is this intentional or an incomplete migration?
2. The `accent` color (`#3b82f6`) is identical to `primary` — are they intended to diverge in the future, or is this redundancy?
3. Should the hardcoded HP adjust colors (red/emerald) be promoted to theme tokens (e.g., `hover-damage`, `hover-heal`)?

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",
"devDependencies": {
"@biomejs/biome": "2.0.0",
"@vitest/coverage-v8": "^3.2.4",
"jscpd": "^4.0.8",
"knip": "^5.85.0",
"lefthook": "^1.11.0",
@@ -20,6 +21,6 @@
"test:watch": "vitest",
"knip": "knip",
"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

@@ -0,0 +1,36 @@
import {
createPlayerCharacter,
type DomainError,
type DomainEvent,
isDomainError,
type PlayerCharacterId,
} from "@initiative/domain";
import type { PlayerCharacterStore } from "./ports.js";
export function createPlayerCharacterUseCase(
store: PlayerCharacterStore,
id: PlayerCharacterId,
name: string,
ac: number,
maxHp: number,
color: string,
icon: string,
): DomainEvent[] | DomainError {
const characters = store.getAll();
const result = createPlayerCharacter(
characters,
id,
name,
ac,
maxHp,
color,
icon,
);
if (isDomainError(result)) {
return result;
}
store.save([...result.characters]);
return result.events;
}

View File

@@ -0,0 +1,23 @@
import {
type DomainError,
type DomainEvent,
deletePlayerCharacter,
isDomainError,
type PlayerCharacterId,
} from "@initiative/domain";
import type { PlayerCharacterStore } from "./ports.js";
export function deletePlayerCharacterUseCase(
store: PlayerCharacterStore,
id: PlayerCharacterId,
): DomainEvent[] | DomainError {
const characters = store.getAll();
const result = deletePlayerCharacter(characters, id);
if (isDomainError(result)) {
return result;
}
store.save([...result.characters]);
return result.events;
}

View File

@@ -0,0 +1,32 @@
import {
type DomainError,
type DomainEvent,
editPlayerCharacter,
isDomainError,
type PlayerCharacterId,
} from "@initiative/domain";
import type { PlayerCharacterStore } from "./ports.js";
interface EditFields {
readonly name?: string;
readonly ac?: number;
readonly maxHp?: number;
readonly color?: string;
readonly icon?: string;
}
export function editPlayerCharacterUseCase(
store: PlayerCharacterStore,
id: PlayerCharacterId,
fields: EditFields,
): DomainEvent[] | DomainError {
const characters = store.getAll();
const result = editPlayerCharacter(characters, id, fields);
if (isDomainError(result)) {
return result;
}
store.save([...result.characters]);
return result.events;
}

View File

@@ -2,8 +2,15 @@ export { addCombatantUseCase } from "./add-combatant-use-case.js";
export { adjustHpUseCase } from "./adjust-hp-use-case.js";
export { advanceTurnUseCase } from "./advance-turn-use-case.js";
export { clearEncounterUseCase } from "./clear-encounter-use-case.js";
export { createPlayerCharacterUseCase } from "./create-player-character-use-case.js";
export { deletePlayerCharacterUseCase } from "./delete-player-character-use-case.js";
export { editCombatantUseCase } from "./edit-combatant-use-case.js";
export type { EncounterStore } from "./ports.js";
export { editPlayerCharacterUseCase } from "./edit-player-character-use-case.js";
export type {
BestiarySourceCache,
EncounterStore,
PlayerCharacterStore,
} from "./ports.js";
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
export { rollAllInitiativeUseCase } from "./roll-all-initiative-use-case.js";

View File

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

View File

@@ -169,9 +169,9 @@ describe("advanceTurn", () => {
});
describe("invariants", () => {
it("INV-1: createEncounter rejects empty combatant list", () => {
it("INV-1: createEncounter accepts empty combatant list", () => {
const result = createEncounter([]);
expect(isDomainError(result)).toBe(true);
expect(isDomainError(result)).toBe(false);
});
it("INV-2: activeIndex always in bounds across all scenarios", () => {

View File

@@ -0,0 +1,227 @@
import { describe, expect, it } from "vitest";
import { createPlayerCharacter } from "../create-player-character.js";
import type { PlayerCharacter } from "../player-character-types.js";
import { playerCharacterId } from "../player-character-types.js";
import { isDomainError } from "../types.js";
const id = playerCharacterId("pc-1");
function success(
characters: readonly PlayerCharacter[],
name: string,
ac: number,
maxHp: number,
color = "blue",
icon = "sword",
) {
const result = createPlayerCharacter(
characters,
id,
name,
ac,
maxHp,
color,
icon,
);
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
}
return result;
}
describe("createPlayerCharacter", () => {
it("creates a valid player character", () => {
const { characters, events } = success(
[],
"Aragorn",
16,
120,
"green",
"shield",
);
expect(characters).toHaveLength(1);
expect(characters[0]).toEqual({
id,
name: "Aragorn",
ac: 16,
maxHp: 120,
color: "green",
icon: "shield",
});
expect(events).toEqual([
{
type: "PlayerCharacterCreated",
playerCharacterId: id,
name: "Aragorn",
},
]);
});
it("trims whitespace from name", () => {
const { characters } = success([], " Gandalf ", 12, 80);
expect(characters[0].name).toBe("Gandalf");
});
it("appends to existing characters", () => {
const existing: PlayerCharacter = {
id: playerCharacterId("pc-0"),
name: "Legolas",
ac: 14,
maxHp: 90,
color: "green",
icon: "eye",
};
const { characters } = success([existing], "Gimli", 18, 100, "red", "axe");
expect(characters).toHaveLength(2);
expect(characters[0]).toEqual(existing);
expect(characters[1].name).toBe("Gimli");
});
it("rejects empty name", () => {
const result = createPlayerCharacter([], id, "", 10, 50, "blue", "sword");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
});
it("rejects whitespace-only name", () => {
const result = createPlayerCharacter(
[],
id,
" ",
10,
50,
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
});
it("rejects negative AC", () => {
const result = createPlayerCharacter(
[],
id,
"Test",
-1,
50,
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
});
it("rejects non-integer AC", () => {
const result = createPlayerCharacter(
[],
id,
"Test",
10.5,
50,
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
});
it("allows AC of 0", () => {
const { characters } = success([], "Test", 0, 50);
expect(characters[0].ac).toBe(0);
});
it("rejects maxHp of 0", () => {
const result = createPlayerCharacter(
[],
id,
"Test",
10,
0,
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
});
it("rejects negative maxHp", () => {
const result = createPlayerCharacter(
[],
id,
"Test",
10,
-5,
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
});
it("rejects non-integer maxHp", () => {
const result = createPlayerCharacter(
[],
id,
"Test",
10,
50.5,
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
});
it("rejects invalid color", () => {
const result = createPlayerCharacter(
[],
id,
"Test",
10,
50,
"neon",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-color");
}
});
it("rejects invalid icon", () => {
const result = createPlayerCharacter(
[],
id,
"Test",
10,
50,
"blue",
"banana",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-icon");
}
});
it("emits exactly one event on success", () => {
const { events } = success([], "Test", 10, 50);
expect(events).toHaveLength(1);
expect(events[0].type).toBe("PlayerCharacterCreated");
});
});

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { deletePlayerCharacter } from "../delete-player-character.js";
import type { PlayerCharacter } from "../player-character-types.js";
import { playerCharacterId } from "../player-character-types.js";
import { isDomainError } from "../types.js";
const id1 = playerCharacterId("pc-1");
const id2 = playerCharacterId("pc-2");
function makePC(overrides?: Partial<PlayerCharacter>): PlayerCharacter {
return {
id: id1,
name: "Aragorn",
ac: 16,
maxHp: 120,
color: "green",
icon: "sword",
...overrides,
};
}
describe("deletePlayerCharacter", () => {
it("deletes an existing character", () => {
const result = deletePlayerCharacter([makePC()], id1);
if (isDomainError(result)) throw new Error(result.message);
expect(result.characters).toHaveLength(0);
});
it("returns error for not-found id", () => {
const result = deletePlayerCharacter([makePC()], id2);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("player-character-not-found");
}
});
it("emits PlayerCharacterDeleted event", () => {
const result = deletePlayerCharacter([makePC()], id1);
if (isDomainError(result)) throw new Error(result.message);
expect(result.events).toHaveLength(1);
expect(result.events[0].type).toBe("PlayerCharacterDeleted");
});
it("preserves other characters when deleting one", () => {
const pc1 = makePC({ id: id1, name: "Aragorn" });
const pc2 = makePC({ id: id2, name: "Legolas" });
const result = deletePlayerCharacter([pc1, pc2], id1);
if (isDomainError(result)) throw new Error(result.message);
expect(result.characters).toHaveLength(1);
expect(result.characters[0].name).toBe("Legolas");
});
it("event includes deleted character name", () => {
const result = deletePlayerCharacter([makePC()], id1);
if (isDomainError(result)) throw new Error(result.message);
const event = result.events[0];
if (event.type !== "PlayerCharacterDeleted") throw new Error("wrong event");
expect(event.name).toBe("Aragorn");
});
});

View File

@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { editPlayerCharacter } from "../edit-player-character.js";
import type { PlayerCharacter } from "../player-character-types.js";
import { playerCharacterId } from "../player-character-types.js";
import { isDomainError } from "../types.js";
const id = playerCharacterId("pc-1");
function makePC(overrides?: Partial<PlayerCharacter>): PlayerCharacter {
return {
id,
name: "Aragorn",
ac: 16,
maxHp: 120,
color: "green",
icon: "sword",
...overrides,
};
}
describe("editPlayerCharacter", () => {
it("edits name successfully", () => {
const result = editPlayerCharacter([makePC()], id, { name: "Strider" });
if (isDomainError(result)) throw new Error(result.message);
expect(result.characters[0].name).toBe("Strider");
expect(result.events[0].type).toBe("PlayerCharacterUpdated");
});
it("edits multiple fields", () => {
const result = editPlayerCharacter([makePC()], id, {
name: "Strider",
ac: 18,
});
if (isDomainError(result)) throw new Error(result.message);
expect(result.characters[0].name).toBe("Strider");
expect(result.characters[0].ac).toBe(18);
});
it("returns error for not-found id", () => {
const result = editPlayerCharacter(
[makePC()],
playerCharacterId("pc-999"),
{ name: "Nope" },
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("player-character-not-found");
}
});
it("rejects empty name", () => {
const result = editPlayerCharacter([makePC()], id, { name: "" });
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
});
it("rejects invalid AC", () => {
const result = editPlayerCharacter([makePC()], id, { ac: -1 });
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
});
it("rejects invalid maxHp", () => {
const result = editPlayerCharacter([makePC()], id, { maxHp: 0 });
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
});
it("rejects invalid color", () => {
const result = editPlayerCharacter([makePC()], id, { color: "neon" });
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-color");
}
});
it("rejects invalid icon", () => {
const result = editPlayerCharacter([makePC()], id, { icon: "banana" });
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-icon");
}
});
it("returns error when no fields changed", () => {
const pc = makePC();
const result = editPlayerCharacter([pc], id, {
name: pc.name,
ac: pc.ac,
});
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("no-changes");
}
});
it("emits exactly one event on success", () => {
const result = editPlayerCharacter([makePC()], id, { name: "Strider" });
if (isDomainError(result)) throw new Error(result.message);
expect(result.events).toHaveLength(1);
});
it("event includes old and new name", () => {
const result = editPlayerCharacter([makePC()], id, { name: "Strider" });
if (isDomainError(result)) throw new Error(result.message);
const event = result.events[0];
if (event.type !== "PlayerCharacterUpdated") throw new Error("wrong event");
expect(event.oldName).toBe("Aragorn");
expect(event.newName).toBe("Strider");
});
});

View File

@@ -0,0 +1,87 @@
import type { DomainEvent } from "./events.js";
import type {
PlayerCharacter,
PlayerCharacterId,
} from "./player-character-types.js";
import {
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
} from "./player-character-types.js";
import type { DomainError } from "./types.js";
export interface CreatePlayerCharacterSuccess {
readonly characters: readonly PlayerCharacter[];
readonly events: DomainEvent[];
}
export function createPlayerCharacter(
characters: readonly PlayerCharacter[],
id: PlayerCharacterId,
name: string,
ac: number,
maxHp: number,
color: string,
icon: string,
): CreatePlayerCharacterSuccess | DomainError {
const trimmed = name.trim();
if (trimmed === "") {
return {
kind: "domain-error",
code: "invalid-name",
message: "Player character name must not be empty",
};
}
if (!Number.isInteger(ac) || ac < 0) {
return {
kind: "domain-error",
code: "invalid-ac",
message: "AC must be a non-negative integer",
};
}
if (!Number.isInteger(maxHp) || maxHp < 1) {
return {
kind: "domain-error",
code: "invalid-max-hp",
message: "Max HP must be a positive integer",
};
}
if (!VALID_PLAYER_COLORS.has(color)) {
return {
kind: "domain-error",
code: "invalid-color",
message: `Invalid color: ${color}`,
};
}
if (!VALID_PLAYER_ICONS.has(icon)) {
return {
kind: "domain-error",
code: "invalid-icon",
message: `Invalid icon: ${icon}`,
};
}
const newCharacter: PlayerCharacter = {
id,
name: trimmed,
ac,
maxHp,
color: color as PlayerCharacter["color"],
icon: icon as PlayerCharacter["icon"],
};
return {
characters: [...characters, newCharacter],
events: [
{
type: "PlayerCharacterCreated",
playerCharacterId: id,
name: trimmed,
},
],
};
}

View File

@@ -75,6 +75,23 @@ export interface Creature {
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. */
export function proficiencyBonus(cr: string): number {
const numericCr = cr.includes("/")

View File

@@ -0,0 +1,39 @@
import type { DomainEvent } from "./events.js";
import type {
PlayerCharacter,
PlayerCharacterId,
} from "./player-character-types.js";
import type { DomainError } from "./types.js";
export interface DeletePlayerCharacterSuccess {
readonly characters: readonly PlayerCharacter[];
readonly events: DomainEvent[];
}
export function deletePlayerCharacter(
characters: readonly PlayerCharacter[],
id: PlayerCharacterId,
): DeletePlayerCharacterSuccess | DomainError {
const index = characters.findIndex((c) => c.id === id);
if (index === -1) {
return {
kind: "domain-error",
code: "player-character-not-found",
message: `Player character not found: ${id}`,
};
}
const removed = characters[index];
const newList = characters.filter((_, i) => i !== index);
return {
characters: newList,
events: [
{
type: "PlayerCharacterDeleted",
playerCharacterId: id,
name: removed.name,
},
],
};
}

View File

@@ -0,0 +1,137 @@
import type { DomainEvent } from "./events.js";
import type {
PlayerCharacter,
PlayerCharacterId,
} from "./player-character-types.js";
import {
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
} from "./player-character-types.js";
import type { DomainError } from "./types.js";
export interface EditPlayerCharacterSuccess {
readonly characters: readonly PlayerCharacter[];
readonly events: DomainEvent[];
}
interface EditFields {
readonly name?: string;
readonly ac?: number;
readonly maxHp?: number;
readonly color?: string;
readonly icon?: string;
}
function validateFields(fields: EditFields): DomainError | null {
if (fields.name !== undefined && fields.name.trim() === "") {
return {
kind: "domain-error",
code: "invalid-name",
message: "Player character name must not be empty",
};
}
if (
fields.ac !== undefined &&
(!Number.isInteger(fields.ac) || fields.ac < 0)
) {
return {
kind: "domain-error",
code: "invalid-ac",
message: "AC must be a non-negative integer",
};
}
if (
fields.maxHp !== undefined &&
(!Number.isInteger(fields.maxHp) || fields.maxHp < 1)
) {
return {
kind: "domain-error",
code: "invalid-max-hp",
message: "Max HP must be a positive integer",
};
}
if (fields.color !== undefined && !VALID_PLAYER_COLORS.has(fields.color)) {
return {
kind: "domain-error",
code: "invalid-color",
message: `Invalid color: ${fields.color}`,
};
}
if (fields.icon !== undefined && !VALID_PLAYER_ICONS.has(fields.icon)) {
return {
kind: "domain-error",
code: "invalid-icon",
message: `Invalid icon: ${fields.icon}`,
};
}
return null;
}
function applyFields(
existing: PlayerCharacter,
fields: EditFields,
): PlayerCharacter {
return {
id: existing.id,
name: fields.name !== undefined ? fields.name.trim() : existing.name,
ac: fields.ac !== undefined ? fields.ac : existing.ac,
maxHp: fields.maxHp !== undefined ? fields.maxHp : existing.maxHp,
color:
fields.color !== undefined
? (fields.color as PlayerCharacter["color"])
: existing.color,
icon:
fields.icon !== undefined
? (fields.icon as PlayerCharacter["icon"])
: existing.icon,
};
}
export function editPlayerCharacter(
characters: readonly PlayerCharacter[],
id: PlayerCharacterId,
fields: EditFields,
): EditPlayerCharacterSuccess | DomainError {
const index = characters.findIndex((c) => c.id === id);
if (index === -1) {
return {
kind: "domain-error",
code: "player-character-not-found",
message: `Player character not found: ${id}`,
};
}
const validationError = validateFields(fields);
if (validationError) return validationError;
const existing = characters[index];
const updated = applyFields(existing, fields);
if (
updated.name === existing.name &&
updated.ac === existing.ac &&
updated.maxHp === existing.maxHp &&
updated.color === existing.color &&
updated.icon === existing.icon
) {
return {
kind: "domain-error",
code: "no-changes",
message: "No fields changed",
};
}
const newList = characters.map((c, i) => (i === index ? updated : c));
return {
characters: newList,
events: [
{
type: "PlayerCharacterUpdated",
playerCharacterId: id,
oldName: existing.name,
newName: updated.name,
},
],
};
}

View File

@@ -1,4 +1,5 @@
import type { ConditionId } from "./conditions.js";
import type { PlayerCharacterId } from "./player-character-types.js";
import type { CombatantId } from "./types.js";
export interface TurnAdvanced {
@@ -103,6 +104,25 @@ export interface EncounterCleared {
readonly combatantCount: number;
}
export interface PlayerCharacterCreated {
readonly type: "PlayerCharacterCreated";
readonly playerCharacterId: PlayerCharacterId;
readonly name: string;
}
export interface PlayerCharacterUpdated {
readonly type: "PlayerCharacterUpdated";
readonly playerCharacterId: PlayerCharacterId;
readonly oldName: string;
readonly newName: string;
}
export interface PlayerCharacterDeleted {
readonly type: "PlayerCharacterDeleted";
readonly playerCharacterId: PlayerCharacterId;
readonly name: string;
}
export type DomainEvent =
| TurnAdvanced
| RoundAdvanced
@@ -119,4 +139,7 @@ export type DomainEvent =
| ConditionRemoved
| ConcentrationStarted
| ConcentrationEnded
| EncounterCleared;
| EncounterCleared
| PlayerCharacterCreated
| PlayerCharacterUpdated
| PlayerCharacterDeleted;

View File

@@ -13,6 +13,12 @@ export {
VALID_CONDITION_IDS,
} from "./conditions.js";
export {
type CreatePlayerCharacterSuccess,
createPlayerCharacter,
} from "./create-player-character.js";
export {
type BestiaryIndex,
type BestiaryIndexEntry,
type BestiarySource,
type Creature,
type CreatureId,
@@ -23,10 +29,18 @@ export {
type SpellcastingBlock,
type TraitBlock,
} from "./creature-types.js";
export {
type DeletePlayerCharacterSuccess,
deletePlayerCharacter,
} from "./delete-player-character.js";
export {
type EditCombatantSuccess,
editCombatant,
} from "./edit-combatant.js";
export {
type EditPlayerCharacterSuccess,
editPlayerCharacter,
} from "./edit-player-character.js";
export type {
AcSet,
CombatantAdded,
@@ -41,6 +55,9 @@ export type {
EncounterCleared,
InitiativeSet,
MaxHpSet,
PlayerCharacterCreated,
PlayerCharacterDeleted,
PlayerCharacterUpdated,
RoundAdvanced,
RoundRetreated,
TurnAdvanced,
@@ -52,6 +69,16 @@ export {
formatInitiativeModifier,
type InitiativeResult,
} from "./initiative.js";
export {
type PlayerCharacter,
type PlayerCharacterId,
type PlayerCharacterList,
type PlayerColor,
type PlayerIcon,
playerCharacterId,
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
} from "./player-character-types.js";
export {
type RemoveCombatantSuccess,
removeCombatant,

View File

@@ -0,0 +1,81 @@
/** Branded string type for player character identity. */
export type PlayerCharacterId = string & {
readonly __brand: "PlayerCharacterId";
};
export function playerCharacterId(id: string): PlayerCharacterId {
return id as PlayerCharacterId;
}
export type PlayerColor =
| "red"
| "blue"
| "green"
| "purple"
| "orange"
| "pink"
| "cyan"
| "yellow"
| "emerald"
| "indigo";
export const VALID_PLAYER_COLORS: ReadonlySet<string> = new Set<PlayerColor>([
"red",
"blue",
"green",
"purple",
"orange",
"pink",
"cyan",
"yellow",
"emerald",
"indigo",
]);
export type PlayerIcon =
| "sword"
| "shield"
| "skull"
| "heart"
| "wand"
| "flame"
| "crown"
| "star"
| "moon"
| "sun"
| "axe"
| "crosshair"
| "eye"
| "feather"
| "zap";
export const VALID_PLAYER_ICONS: ReadonlySet<string> = new Set<PlayerIcon>([
"sword",
"shield",
"skull",
"heart",
"wand",
"flame",
"crown",
"star",
"moon",
"sun",
"axe",
"crosshair",
"eye",
"feather",
"zap",
]);
export interface PlayerCharacter {
readonly id: PlayerCharacterId;
readonly name: string;
readonly ac: number;
readonly maxHp: number;
readonly color: PlayerColor;
readonly icon: PlayerIcon;
}
export interface PlayerCharacterList {
readonly characters: readonly PlayerCharacter[];
}

View File

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

View File

@@ -7,6 +7,7 @@ export function combatantId(id: string): CombatantId {
import type { ConditionId } from "./conditions.js";
import type { CreatureId } from "./creature-types.js";
import type { PlayerCharacterId } from "./player-character-types.js";
export interface Combatant {
readonly id: CombatantId;
@@ -18,6 +19,9 @@ export interface Combatant {
readonly conditions?: readonly ConditionId[];
readonly isConcentrating?: boolean;
readonly creatureId?: CreatureId;
readonly color?: string;
readonly icon?: string;
readonly playerCharacterId?: PlayerCharacterId;
}
export interface Encounter {
@@ -38,8 +42,8 @@ function domainError(code: string, message: string): DomainError {
/**
* Creates a valid Encounter, enforcing INV-1, INV-2, INV-3.
* - INV-1: At least one combatant required.
* - INV-2: activeIndex defaults to 0 (always in bounds).
* - INV-1: An encounter MAY have zero combatants.
* - INV-2: activeIndex defaults to 0 (always in bounds when combatants exist).
* - INV-3: roundNumber defaults to 1 (positive integer).
*/
export function createEncounter(
@@ -47,13 +51,10 @@ export function createEncounter(
activeIndex = 0,
roundNumber = 1,
): Encounter | DomainError {
if (combatants.length === 0) {
return domainError(
"invalid-encounter",
"An encounter must have at least one combatant",
);
}
if (activeIndex < 0 || activeIndex >= combatants.length) {
if (
combatants.length > 0 &&
(activeIndex < 0 || activeIndex >= combatants.length)
) {
return domainError(
"invalid-encounter",
`activeIndex ${activeIndex} out of bounds for ${combatants.length} combatants`,

897
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -47,40 +47,34 @@ function matchesForbidden(importPath, forbidden) {
return importPath === forbidden || importPath.startsWith(`${forbidden}/`);
}
/** @returns {{ file: string, line: number, importPath: string, forbidden: string }[]} */
export function checkLayerBoundaries() {
const IMPORT_RE =
/(?: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 }[]} */
const violations = [];
const content = readFileSync(file, "utf-8");
const lines = content.split("\n");
for (const [srcDir, forbidden] of Object.entries(FORBIDDEN)) {
const absDir = join(ROOT, srcDir);
let files;
try {
files = collectTsFiles(absDir);
} catch {
continue;
}
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(IMPORT_RE);
if (!match) continue;
for (const file of files) {
const content = readFileSync(file, "utf-8");
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const match = line.match(
/(?:import|from)\s+["']([^"']+)["']|require\s*\(\s*["']([^"']+)["']\s*\)/,
);
if (!match) continue;
const importPath = match[1] || match[2];
for (const f of forbidden) {
if (matchesForbidden(importPath, f)) {
violations.push({
file: relative(ROOT, file),
line: i + 1,
importPath,
forbidden: f,
});
}
}
const importPath = match[1] || match[2];
for (const f of forbidden) {
if (matchesForbidden(importPath, f)) {
violations.push({
file: relative(ROOT, file),
line: i + 1,
importPath,
forbidden: f,
});
}
}
}
@@ -88,6 +82,30 @@ export function checkLayerBoundaries() {
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
if (
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)

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