Compare commits

...

46 Commits

Author SHA1 Message Date
Lukas c295840b7b Update CLAUDE.md for jsinspect, TS compiler props, parallel lefthook
CI / check (push) Successful in 1m39s
CI / build-image (push) Has been skipped
Adds jsinspect to check description and tech stack, removes incorrect
routing mention, notes prop checker uses TS compiler API, updates
quality gates to reflect parallel lefthook jobs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:29:48 +02:00
Lukas d13641152f Update README with setup guide, contributing workflow, and bestiary docs
Fixes packages/app → packages/application path, expands scripts table,
documents the parallel merge gate, adds contributing workflow with
spec-driven process and Claude Code skills, and documents bestiary
index regeneration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:48:11 +02:00
Lukas 110f4726ae Add tests for Dialog and Tooltip, raise components/ui threshold to 93%
Dialog: open/close lifecycle, cancel event handling, DialogHeader.
Tooltip: show on pointer enter, hide on pointer leave. Raises
components/ui coverage threshold to enforce testing of future
primitives.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:22:17 +02:00
Lukas 2bc22369ce Add tests for ConditionTags and CreatePlayerModal
ConditionTags: rendering, remove callback, add picker callback.
CreatePlayerModal: create/edit modes, form validation (name, AC, HP,
level), error display and clearing, onSave/onClose callbacks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 12:36:07 +02:00
Lukas 2971d32f45 Add action-bar tests for overflow menu, dialogs, and custom stats
Tests browse mode toggle, export/import dialog opening, overflow menu
callbacks (manage players, settings), and custom stat field submission.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 03:26:57 +02:00
Lukas a97044ec3e Add tests for useActionBarState hook
Tests search/suggestion filtering, queued creature counting, form
submission with custom stats, browse mode, and dismiss/clear behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 00:16:54 +01:00
Lukas a77db0eeee Add quick-win tests for components and hooks
Adds tests for DifficultyIndicator, Toast, RollModeMenu, OverflowMenu,
useTheme, and useRulesEdition. Covers rendering, user interactions,
auto-dismiss timers, external store sync, and localStorage persistence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 19:32:15 +01:00
Lukas d8c8a0c44d Add direct reducer tests for encounterReducer
Exports encounterReducer and EncounterState for testing. Adds 26
pure-function tests covering all action types: CRUD, turn navigation,
HP/AC/conditions, undo/redo, bestiary add with auto-numbering,
player character add, import, and event accumulation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:50:45 +01:00
Lukas 80dd68752e Refactor useEncounter from useState to useReducer
Replaces 18 useCallback wrappers with a typed action union and
encounterReducer. Undo/redo wrapping is now systematic per-case in
the reducer instead of ad-hoc per operation. Complex cases (undo/redo,
bestiary add, player character add) are extracted into helper functions.

The stat block auto-show on bestiary add now uses lastCreatureId from
reducer state instead of the synchronous return value, with a useEffect
in use-action-bar-state to react to changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:41:40 +01:00
Lukas 896fd427ed Add tests for undo/redo/setTempHp use cases, fix coverage thresholds
Adds missing tests for undoUseCase, redoUseCase, and setTempHpUseCase,
bringing application layer coverage from ~81% to 97%. Removes
autoUpdate from coverage thresholds and sets floors to actual values
so they enforce a real minimum.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:19:15 +01:00
Lukas 01b1bba6d6 Replace regex prop counter with TypeScript compiler API
Uses ts.createProgram to parse real AST instead of regex + brace-depth
state machine. Immune to comments, strings, and complex type syntax.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:11:34 +01:00
Lukas b7a97c3d88 Parallelize pre-commit checks via lefthook jobs
Independent checks (audit, knip, biome, jscpd, jsinspect, custom
scripts) now run in parallel. Type-dependent checks (oxlint, vitest)
remain sequential after tsc --build via a piped group. Also reorder
pnpm check for fast-fail on cheap checks first.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:07:38 +01:00
Lukas 1de00e3d8e Move entity rehydration to domain layer, fix tempHp gap
CI / check (push) Successful in 1m16s
CI / build-image (push) Has been skipped
Rehydration functions (reconstructing typed domain objects from untyped
JSON) lived in persistence adapters, duplicating domain validation.
Adding a field required updating both the domain type and a separate
adapter function — the adapter was missed for `level`, silently dropping
it on reload. Now adding a field only requires updating the domain type
and its co-located rehydration function.

- Add `rehydratePlayerCharacter` and `rehydrateCombatant` to domain
- Persistence adapters delegate to domain instead of reimplementing
- Add `tempHp` validation (was silently dropped during rehydration)
- Tighten initiative validation to integer-only
- Exhaustive domain tests (53 cases); adapter tests slimmed to round-trip
- Remove stale `jsinspect-plus` Knip ignoreDependencies entry

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:12:41 +01:00
Lukas f4fb69dbc7 Add jsinspect-plus structural duplication gate, extract shared helpers
CI / check (push) Successful in 1m13s
CI / build-image (push) Has been skipped
Add jsinspect-plus (AST-based structural duplication detector) to pnpm
check with threshold 50 / min 3 instances. Fix all findings:

- Extract condition icon/color maps to shared condition-styles.ts
- Extract useClickOutside hook (5 components)
- Extract dispatchAction + resolveAndRename in use-encounter
- Extract runEncounterAction in application layer (13 use cases)
- Extract findCombatant helper in domain (9 functions)
- Extract TraitSection in stat-block (4 trait rendering blocks)
- Extract DialogHeader in dialog.tsx (4 dialogs)

Net result: -263 lines across 40 files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 02:16:54 +01:00
Lukas ef76b9c90b Add encounter difficulty indicator (5.5e XP budget)
CI / check (push) Successful in 1m13s
CI / build-image (push) Successful in 16s
Live 3-bar difficulty indicator in the top bar showing encounter
difficulty (Trivial/Low/Moderate/High) based on the 2024 5.5e XP
budget system. Automatically derived from PC levels and bestiary
creature CRs.

- Add optional level field (1-20) to PlayerCharacter
- Add CR-to-XP and XP Budget per Character lookup tables in domain
- Add calculateEncounterDifficulty pure function
- Add DifficultyIndicator component with color-coded bars and tooltip
- Add useDifficulty hook composing encounter, PC, and bestiary contexts
- Indicator hidden when no PCs with levels or no bestiary-linked monsters
- Level field in PC create/edit forms, persisted in storage

Closes #18

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:55:48 +01:00
Lukas 36122b500b Add import/export to README, research scope guidance to CLAUDE.md
CI / check (push) Successful in 1m11s
CI / build-image (push) Successful in 16s
Add import/export feature bullet to README.md (constitution requires
README updates when user-facing capabilities change). Add research
scope note to CLAUDE.md RPI section: research phases should scan for
existing patterns and consolidation opportunities, not just what the
feature needs. Remove auto-generated Active Technologies / Recent
Changes sections that duplicated Tech Stack.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:04:32 +01:00
Lukas f4355a8675 Add optional export filename, tests for post-implement features
Add optional filename field to export dialog with automatic .json
extension handling. Extract resolveFilename() for testability. Add
tests for includeHistory flag, bundleToJson, and filename resolution.
Add export format compatibility note to CLAUDE.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:42:50 +01:00
Lukas 209df13c32 Add export method dialog, extract shared Dialog primitive
Add export dialog with download/clipboard options and optional
undo/redo history inclusion (default off). Extract shared Dialog
component to ui/dialog.tsx, consolidating open/close lifecycle,
backdrop click, and escape key handling from all 6 dialog components.
Update spec to reflect export method dialog and optional history.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:57:31 +01:00
Lukas 4969ed069b Add import method dialog with file upload and paste options
Replace direct file picker trigger with a modal offering two import
methods: file upload and paste JSON content. Uses a textarea instead
of navigator.clipboard.readText() to avoid browser permission prompts.
Also centers both import dialogs and updates spec for clipboard import.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:43:22 +01:00
Lukas fba83bebd6 Add JSON import/export for full encounter state
Export and import encounter, undo/redo history, and player characters
as a downloadable .json file. Export/import actions are in the action
bar overflow menu. Import validates using existing rehydration functions
and shows a confirmation dialog when replacing a non-empty encounter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:28:39 +01:00
Lukas f6766b729d Rename spec 037-undo-redo to 006-undo-redo for sequential numbering
Delete merged feature branches (005–037) that inflated the auto-increment
counter in create-new-feature.sh, and renumber the undo-redo spec to
follow the existing 001–005 sequence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:32:29 +01:00
Lukas f10c67a5ba Dismiss side panel when encounter becomes empty
CI / check (push) Successful in 1m7s
CI / build-image (push) Successful in 15s
Closes the stat block / source manager panel when the last combatant
is removed or the encounter is cleared, giving a fully clean state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 00:16:32 +01:00
Lukas 9437272fe0 Batch bestiary add produces a single undo entry
CI / check (push) Successful in 1m10s
CI / build-image (push) Successful in 15s
Extract addOneFromBestiary (no undo) and build addMultipleFromBestiary
on top so confirming N creatures from the bestiary panel creates one
undo entry that restores the entire batch, not N individual entries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 00:07:25 +01:00
Lukas 541e04b732 Wrap initiative rolls with undo so they produce undo entries
Initiative rolls (single and bulk) called makeStore() directly from
useInitiativeRolls, bypassing the withUndo wrapper. Expose withUndo
from the encounter context and wrap both roll paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 00:06:50 +01:00
Lukas e9fd896934 Clean up gitignore and CLAUDE.md
CI / check (push) Successful in 1m8s
CI / build-image (push) Successful in 30s
Add .rodney/ to gitignore. Remove redundant Active Technologies and
Recent Changes sections from CLAUDE.md — info already covered by
Tech Stack and Data & Storage sections.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 23:36:52 +01:00
Lukas 29cdd19cab Roll back renames on failed compound add operations
addFromBestiary and addFromPlayerCharacter rename existing combatants
before adding the new one. If the add fails, the renames were applied
without an undo entry. Restore the pre-operation snapshot on failure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 23:31:11 +01:00
Lukas 17cc6ed72c Add undo/redo for all encounter actions
Memento-based undo/redo with full encounter snapshots. Undo stack
capped at 50 entries, persisted to localStorage. Triggered via
buttons in the top bar (inboard of turn navigation) and keyboard
shortcuts (Ctrl+Z / Ctrl+Shift+Z, Cmd on Mac, case-insensitive key
matching). Clear encounter resets both stacks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 23:30:33 +01:00
Lukas 9d81c8ad27 Atomic addCombatant with optional CombatantInit bag
addCombatant now accepts an optional init parameter for pre-filled stats
(HP, AC, initiative, creatureId, color, icon, playerCharacterId), making
combatant creation a single atomic operation with domain validation.

This eliminates the multi-step store.save() bypass in addFromBestiary and
addFromPlayerCharacter, and removes the CombatantOpts/applyCombatantOpts
helpers. Also extracts shared initiative sort logic into initiative-sort.ts
used by both addCombatant and setInitiative.

Closes #15

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:13:20 +01:00
Lukas 7199b9d2d9 Add browser-interactive-testing skill and fix Biome/audit config
Integrate the rodney/showboat browser automation skill for headless
Chrome screenshots and testing. Exclude .rodney and .agent-tests
from Biome file scanning. Add picomatch override to resolve
high-severity ReDoS vulnerability in knip/jscpd transitive deps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:10:57 +01:00
Lukas 158bcf1468 Add ADRs for branded types, bestiary loading, and pre-commit gates
ADR-003: Branded types for compile-time identity safety at zero
runtime cost.
ADR-004: On-demand bestiary via compact index + IndexedDB cache,
avoiding distribution of copyrighted content.
ADR-005: All quality gates at pre-commit for tight agent feedback
loops, with analysis of per-change hooks as a future option.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:02:48 +01:00
Lukas fab9301b20 Decompose ActionBar into hook and focused sub-components
CI / check (push) Successful in 1m7s
CI / build-image (push) Has been skipped
Extract useActionBarState hook with all search/queue/mode state and
handlers. Extract RollAllButton (context-consuming, zero props),
BrowseSuggestions, CustomStatFields, and refactor AddModeSuggestions
to use grouped SuggestionActions interface (11 props → 6).

ActionBar is now a ~120-line layout shell.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:41:35 +01:00
Lukas d653cfe489 Add ADR template and first two architecture decision records
Document the errors-as-values pattern (ADR-001) and domain events
as plain data objects (ADR-002) to capture the reasoning behind
these foundational design choices.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:19:54 +01:00
Lukas 228a2603e8 Add Sapped and Slowed conditions for 5.5e weapon mastery
CI / check (push) Successful in 1m8s
CI / build-image (push) Successful in 15s
These D&D 2024 weapon mastery conditions are edition-gated: they only
appear in the condition picker when 5.5e rules are selected. Applied
conditions still render correctly regardless of edition setting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:31:41 +01:00
Lukas 27ff8ba1ad Collapse hover-only buttons to zero width when hidden
CI / check (push) Successful in 1m8s
CI / build-image (push) Successful in 16s
Edit and add-condition buttons now take no space when not hovered,
eliminating the gap between name and condition icons. They slide in
smoothly on hover with a 150ms transition.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:19:47 +01:00
Lukas 4cfcefe6c3 Hide custom stat fields on mobile, fix action bar gap consistency
CI / check (push) Successful in 1m7s
CI / build-image (push) Successful in 16s
Init/AC/MaxHP inputs are hidden on phones — users set these values
directly in the combatant row after adding. Fixes uneven spacing
between action bar elements by using consistent gap-3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:41:18 +01:00
Lukas 8baccf3cd3 Merge 006-mobile-touch-targets: mobile foundation and bug fixes
CI / check (push) Successful in 1m8s
CI / build-image (push) Successful in 16s
- iOS zoom fix (16px input font)
- Safe area insets for notched phones
- viewport-fit=cover
- Action bar flex-wrap for narrow screens
- Slightly increased row padding on mobile
- Fix stat block panel showing wrong creature on first open
- Skip auto-opening stat block when adding on mobile
2026-03-24 23:26:33 +01:00
Lukas a9ca31e9bc Skip auto-opening stat block panel when adding creatures on mobile
On desktop the panel has room alongside the combatant list, but on
mobile it covers the screen and disrupts the add-combatant flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:22:54 +01:00
Lukas 64a1f0b8db Add mobile foundation: iOS zoom fix, safe area insets, row padding
- Input base font 16px on mobile to prevent iOS Safari auto-zoom
- Safe area insets for notched phones (top/bottom bars)
- viewport-fit=cover to enable safe area env() values
- Action bar flex-wrap for custom stat field overflow
- Slightly increased row padding on mobile (py-3 sm:py-2)
- Removed redundant font-size classes from Input usages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:19:10 +01:00
Lukas 5e5812bcaa Fix stat block panel showing wrong creature on first open
useAutoStatBlock was overriding the user's creature selection when
the panel transitioned from closed to open. Now only auto-updates
when the active turn index changes (advance/retreat), not when the
panel mode changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:18:49 +01:00
Lukas 9e09c8ae2a Sync theme-color meta tag with active light/dark theme
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:59:13 +01:00
Lukas 4d0ec0c7b2 Add Open Graph meta tags for link previews in messengers
CI / check (push) Successful in 1m8s
CI / build-image (push) Successful in 15s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:54:05 +01:00
Lukas fe62f2eb2f Add PWA manifest, app icons, and favicon
D20-themed icon in app color scheme for home screen installation,
favicon, and apple-touch-icon. Standalone display mode makes the
app feel native when launched from home screen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:51:28 +01:00
Lukas 7092677273 Make layout full-width on mobile with docked top/bottom bars
Remove max-width constraint and horizontal padding on small screens
so content goes edge-to-edge. Turn navigation and action bar lose
rounded corners on mobile and dock flush to top/bottom edges.
Desktop layout (sm: and up) is unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:36:01 +01:00
Lukas e1a06c9d59 Tighten combatant row grid on mobile for better name visibility
Reduce grid gap and initiative column width on small screens,
restoring full layout at the sm breakpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:35:42 +01:00
Lukas 4043612ccf Add rules edition setting for condition tooltips (5e/5.5e)
CI / check (push) Successful in 1m8s
CI / build-image (push) Successful in 16s
Introduce a settings modal (opened from the kebab menu) with a rules
edition selector for condition tooltip descriptions and a theme picker
replacing the inline cycle button. About half the conditions have
meaningful mechanical differences between editions.

- Add description5e field to ConditionDefinition with 5e (2014) text
- Add RulesEditionProvider context with localStorage persistence
- Create SettingsModal with Conditions and Theme sections
- Wire condition tooltips to edition-aware descriptions
- Fix 6 inaccurate 5.5e condition descriptions
- Update spec 003 with stories CC-3, CC-8 and FR-095–FR-102

Closes #12

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:08:41 +01:00
Lukas cfd4aef724 Expand pre-2024 {@atk} tags to full attack type labels in stat blocks
Old 5etools data uses {@atk mw} instead of {@atkr m}, which the generic
tag handler was reducing to bare "mw" text. Adds dedicated handling for
all {@atk} variants and bumps the bestiary cache version to clear stale
processed data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:11:54 +01:00
170 changed files with 10286 additions and 1951 deletions
@@ -0,0 +1,206 @@
---
name: browser-interactive-testing
description: >
This skill should be used when the user asks to "test a web page",
"take a screenshot of a site", "automate browser interaction",
"create a test report", "verify a page works", or mentions
rodney, showboat, headless Chrome testing, or browser automation.
version: 0.1.0
---
# Browser Interactive Testing
Test web pages interactively using **rodney** (headless Chrome automation) and document results with **showboat** (executable demo reports).
## Prerequisites
Ensure `uv` is installed. If missing, instruct the user to run:
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
Do NOT install rodney or showboat globally. Run them via `uvx`:
```bash
uvx rodney <command>
uvx showboat <command>
```
## Rodney Quick Reference
### Start a browser session
```bash
uvx rodney start # Launch headless Chrome
uvx rodney start --show # Launch visible browser (for debugging)
uvx rodney connect host:port # Connect to existing Chrome with remote debugging
```
Use `--local` on all commands to scope the session to the current directory.
### Navigate and inspect
```bash
uvx rodney open "https://example.com"
uvx rodney waitload
uvx rodney title
uvx rodney url
uvx rodney text "h1"
uvx rodney html "#content"
```
### Interact with elements
```bash
uvx rodney click "#submit-btn"
uvx rodney input "#email" "user@example.com"
uvx rodney select "#country" "US"
uvx rodney js "document.querySelector('#app').dataset.ready"
```
### Assert and verify
```bash
uvx rodney assert "document.title" "My App" -m "Title must match"
uvx rodney exists ".error-banner"
uvx rodney visible "#loading-spinner"
uvx rodney count ".list-item"
```
Exit code `0` = pass, `1` = fail, `2` = error.
### Screenshots and cleanup
```bash
uvx rodney screenshot -w 1280 -h 720 page.png
uvx rodney screenshot-el "#chart" chart.png
uvx rodney stop
```
Run `uvx rodney --help` for the full command list, including tab management, navigation, waiting, accessibility tree inspection, and PDF export.
## Showboat Quick Reference
```bash
uvx showboat init report.md "Test Report Title"
uvx showboat note report.md "Description of what we are testing."
uvx showboat exec report.md bash "uvx rodney title --local"
uvx showboat image report.md '![Page screenshot](screenshot.png)'
uvx showboat pop report.md # Remove last entry (fix mistakes)
uvx showboat verify report.md # Re-run all code blocks and diff
uvx showboat extract report.md # Print commands that recreate the document
```
Run `uvx showboat --help` for details on `--workdir`, `--output`, `--filename`, and stdin piping.
## Output Directory
Save all reports under `.agent-tests/` in the project root:
```
.agent-tests/
└── YYYY-MM-DD-<slug>/
├── report.md
└── screenshots/
```
Derive the slug from the test subject (e.g., `login-flow`, `homepage-layout`). Keep it lowercase, hyphen-separated, max ~30 chars. If a directory with the same date and slug already exists, append a numeric suffix (e.g., `tetris-game-2`) or choose a more specific slug (e.g., `tetris-controls` instead of reusing `tetris-game`).
### Setup Script
Run the bundled `scripts/setup.py` to create the directory, init the report, start the browser, and capture `DIR` in one step. Replace `<SKILL_DIR>` with the actual path to the directory containing this skill's files:
```bash
DIR=$(python3 <SKILL_DIR>/scripts/setup.py "<slug>" "<Report Title>")
```
This single command:
1. Creates `.agent-tests/YYYY-MM-DD-<slug>/screenshots/`
2. Adds `.rodney/` to `.gitignore` (if `.gitignore` exists)
3. Runs `showboat init` for the report
4. Starts a browser (connects to existing, launches system Chrome/Chromium, or falls back to rodney's built-in launcher)
5. Prints the directory path to stdout (all status messages go to stderr)
After setup, `$DIR` is ready for use with all subsequent commands.
**Important:** The `--local` flag stores session data in `.rodney/` relative to the current working directory. Do NOT `cd` to a different directory during the session, or rodney will lose the connection. Use absolute paths for file arguments instead.
## Workflow
1. **Setup** — Run the setup script to create the dir, init the report, start the browser, and set `$DIR`
2. **Describe the test**`uvx showboat note "$DIR/report.md" "Testing [subject] for [goals]."` so the report has context up front
3. **Open page**`uvx showboat exec "$DIR/report.md" bash "uvx rodney open --local 'URL' && uvx rodney waitload --local"`
4. **Add a note** before each test group — Use a heading followed by a short explanation of what the tests in this section verify and why it matters. Use unique section titles; avoid duplicating headings within the same report.
```bash
uvx showboat note "$DIR/report.md" "## Keyboard Controls"
uvx showboat note "$DIR/report.md" "Verify arrow keys move and rotate the active piece, and that soft/hard drop work correctly."
```
5. **Run assertions** — Before each assertion, add a short `showboat note` explaining what it checks. Then wrap the `rodney assert` / `rodney js` call in `showboat exec`:
```bash
uvx showboat note "$DIR/report.md" "The left arrow key should move the piece one cell to the left."
uvx showboat exec "$DIR/report.md" bash "uvx rodney assert --local '...' '...' -m 'Piece moved left'"
```
6. **Capture screenshots** — Take the screenshot with `rodney screenshot`, then embed with `showboat image`. **Important:** `showboat image` resolves image paths relative to the current working directory, NOT relative to the report file. Always use absolute paths (`$DIR/screenshots/...`) in the markdown image reference to avoid "image file not found" errors:
```bash
uvx rodney screenshot --local -w 1280 -h 720 "$DIR/screenshots/01-initial-load.png"
uvx showboat image "$DIR/report.md" "![Initial load]($DIR/screenshots/01-initial-load.png)"
```
Number screenshots sequentially (`01-`, `02-`, ...) and use descriptive filenames.
7. **Pop on failure** — If a command fails, run `showboat pop` then retry
8. **Stop browser** — `uvx rodney stop --local`
9. **Write summary** — Add a final `showboat note` with a summary section listing all pass/fail results and any bugs found. Every report must end with a summary.
10. **Verify report** — `uvx showboat verify "$DIR/report.md"`
### Best Practices
- Use `uvx rodney waitload` or `uvx rodney wait <selector>` before interacting with page content.
- Run `uvx showboat pop` immediately after a failed `exec` to keep the report clean.
- Prefer `rodney assert` for checks — clear exit codes and self-documenting output.
- Use `rodney js` only for complex checks or state manipulation that `assert` cannot express.
- Take screenshots at key stages (initial load, after interaction, error states) for visual evidence.
- Add a `showboat note` before each logical group of tests with a heading and a short explanation of what the section tests. Use unique heading titles — duplicate headings make the report confusing.
- Always end reports with a summary `showboat note` listing pass/fail results and any bugs found. This is required, not optional.
## Quoting Rules for `rodney js`
`rodney js` evaluates a single JS **expression** (not statements). Nested shell quoting with `showboat exec` causes most errors. Follow these rules strictly:
1. **Wrap multi-statement JS in an IIFE** — bare `const`, `let`, `for` fail at top level:
```bash
# WRONG
uvx rodney js --local 'const x = 1; x + 2'
# CORRECT
uvx rodney js --local '(function(){ var x = 1; return x + 2; })()'
```
2. **Use `var` instead of `const`/`let`** inside IIFEs to avoid strict-mode eval scoping issues.
3. **Direct `rodney js` calls** — use single quotes for the outer shell, double quotes inside JS:
```bash
uvx rodney js --local '(function(){ var el = document.querySelector("#app"); return el.textContent; })()'
```
4. **Inside `showboat exec`** — use a heredoc with a **quoted delimiter** (`<<'JSEOF'`) to prevent all shell expansion (`$`, backticks, etc.):
```bash
uvx showboat exec "$DIR/report.md" bash "$(cat <<'JSEOF'
uvx rodney js --local '
(function(){
var x = score;
hardDrop();
return "before:" + x + ",after:" + score;
})()
'
JSEOF
)"
```
For simple one-liners, single quotes inside the double-quoted bash arg also work:
```bash
uvx showboat exec "$DIR/report.md" bash "uvx rodney js --local '(function(){ return String(score); })()'"
```
5. **Avoid without heredoc**: backticks, `$` signs, unescaped double quotes. The heredoc pattern avoids all of these.
6. **Prefer `rodney assert` over `rodney js`** when possible — separate arguments avoid quoting entirely.
7. **Pop after syntax errors** — always `showboat pop` before retrying to keep the report clean.
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Set up a browser-interactive-testing session.
Creates the output directory, inits the showboat report, starts a browser,
and prints the DIR path. Automatically detects whether rodney can launch
its own Chromium or falls back to a system-installed browser.
"""
import datetime
import os
import shutil
import socket
import subprocess
import sys
import time
REMOTE_DEBUG_PORT = 9222
def find_system_browser():
"""Return the path to a system Chrome/Chromium binary, or None."""
for name in ["chromium", "chromium-browser", "google-chrome", "google-chrome-stable"]:
path = shutil.which(name)
if path:
return path
return None
def port_listening(port):
"""Check if something is already listening on the given port."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(1)
return s.connect_ex(("localhost", port)) == 0
def try_connect(port):
"""Try to connect rodney to a browser on the given port. Returns True on success."""
result = subprocess.run(
["uvx", "rodney", "connect", "--local", f"localhost:{port}"],
capture_output=True,
text=True,
)
if result.returncode == 0:
print(f"Connected to existing browser on port {port}", file=sys.stderr)
return True
return False
def launch_system_browser(browser_path):
"""Launch a system browser with remote debugging and wait for it to be ready."""
subprocess.Popen(
[
browser_path,
"--headless",
"--disable-gpu",
f"--remote-debugging-port={REMOTE_DEBUG_PORT}",
"--no-sandbox",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# Wait for the browser to start listening
for _ in range(20):
if port_listening(REMOTE_DEBUG_PORT):
return True
time.sleep(0.25)
return False
def start_browser():
"""Start a headless browser and connect rodney to it.
Strategy order (fastest path first):
1. Connect to an already-running browser on the debug port.
2. Launch a system Chrome/Chromium (avoids rodney's Chromium download,
which fails on some architectures like Linux ARM64).
3. Let rodney launch its own browser as a last resort.
"""
# Strategy 1: connect to an already-running browser
if port_listening(REMOTE_DEBUG_PORT) and try_connect(REMOTE_DEBUG_PORT):
return
# Strategy 2: launch a system browser (most reliable on Linux)
browser = find_system_browser()
if browser:
print(f"Launching system browser: {browser}", file=sys.stderr)
if launch_system_browser(browser):
if try_connect(REMOTE_DEBUG_PORT):
return
print("WARNING: system browser started but rodney could not connect", file=sys.stderr)
else:
print("WARNING: system browser did not start in time", file=sys.stderr)
# Strategy 3: let rodney try its built-in launcher
result = subprocess.run(
["uvx", "rodney", "start", "--local"],
capture_output=True,
text=True,
)
if result.returncode == 0:
print("Browser started via rodney", file=sys.stderr)
return
print(
"ERROR: Could not start a browser. Tried:\n"
f" - Connecting to localhost:{REMOTE_DEBUG_PORT} (no browser found)\n"
f" - System browser: {browser or 'not found'}\n"
" - rodney start (failed)\n"
"Install chromium or google-chrome and try again.",
file=sys.stderr,
)
sys.exit(1)
def ensure_gitignore_entry(entry):
"""Add entry to .gitignore if the file exists and the entry is missing."""
gitignore = ".gitignore"
if not os.path.isfile(gitignore):
return
with open(gitignore, "r") as f:
content = f.read()
# Check if the entry (with or without trailing slash/newline variations) is already present
lines = content.splitlines()
if any(line.strip() == entry or line.strip() == entry.rstrip("/") for line in lines):
return
# Append the entry
with open(gitignore, "a") as f:
if content and not content.endswith("\n"):
f.write("\n")
f.write(f"{entry}\n")
print(f"Added '{entry}' to .gitignore", file=sys.stderr)
def main():
if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} <slug> <report-title>", file=sys.stderr)
sys.exit(1)
slug = sys.argv[1]
title = sys.argv[2]
# Create output directory
d = f".agent-tests/{datetime.date.today()}-{slug}"
os.makedirs(f"{d}/screenshots", exist_ok=True)
# Ensure .rodney/ is in .gitignore (rodney stores session files there)
ensure_gitignore_entry(".rodney/")
# Init showboat report
subprocess.run(["uvx", "showboat", "init", f"{d}/report.md", title], check=True)
# Start browser
start_browser()
# Print the directory path (only real stdout, everything else goes to stderr)
print(d)
if __name__ == "__main__":
main()
+1
View File
@@ -12,3 +12,4 @@ Thumbs.db
coverage/
*.tsbuildinfo
docs/agents/plans/
.rodney/
+9
View File
@@ -0,0 +1,9 @@
{
"threshold": 50,
"minInstances": 3,
"identifiers": false,
"literals": false,
"ignore": "dist|__tests__|node_modules",
"reporter": "default",
"truncate": 100
}
+11 -12
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 (audit + knip + biome + oxlint + typecheck + test/coverage + jscpd)
pnpm check # Merge gate — must pass before every commit (audit + knip + biome + oxlint + typecheck + test/coverage + jscpd + jsinspect)
pnpm oxlint # Type-aware linting (oxlint — complements Biome)
pnpm knip # Unused code detection (Knip)
pnpm test # Run all tests (Vitest)
@@ -30,7 +30,7 @@ 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 (`EncounterStore`, `BestiarySourceCache`). No business logic here.
- **Web** — React adapter. Implements ports using hooks/state. All UI components, routing, and user interaction live here.
- **Web** — React adapter. Implements ports using hooks/state. All UI components 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.
@@ -60,7 +60,7 @@ docs/agents/ RPI skill artifacts (research reports, plans)
- React 19, Vite 6, Tailwind CSS v4
- Lucide React (icons)
- `idb` (IndexedDB wrapper for bestiary cache)
- Biome 2.4 (formatting + linting), oxlint (type-aware linting), Knip (unused code), jscpd (copy-paste detection)
- Biome 2.4 (formatting + linting), oxlint (type-aware linting), Knip (unused code), jscpd (copy-paste detection), jsinspect-plus (structural duplication)
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
## Conventions
@@ -72,8 +72,9 @@ docs/agents/ RPI skill artifacts (research reports, plans)
- **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/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`.
- **Component props** — max 8 explicitly declared props per component interface (enforced by `scripts/check-component-props.mjs`). Use React context for shared state; reserve props for per-instance config (data items, layout variants, refs).
- **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.
- **Component props** — max 8 explicitly declared props per component interface (enforced by `scripts/check-component-props.mjs` using the TypeScript compiler API). Use React context for shared state; reserve props for per-instance config (data items, layout variants, refs).
- **Export format compatibility** — When changing `Encounter`, `Combatant`, `PlayerCharacter`, or `UndoRedoState` types, verify that previously exported JSON files (version 1) still import correctly. If not, bump the `ExportBundle` version and add migration logic in `validateImportBundle()`.
- **Quality gates** are enforced at pre-commit via Lefthook (parallel jobs). No gate may exist only as a CI step or manual process.
## Self-Review Checklist
@@ -97,6 +98,8 @@ Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Spec
- `rpi-plan` — interactive phased implementation plan in `docs/agents/plans/`
- `rpi-implement` — execute a plan file phase by phase with automated + manual verification
**Research scope**: Research should include a scan for existing patterns similar to what the feature needs (e.g., shared UI primitives, duplicated validation logic, repeated state management patterns). Identify extraction and consolidation opportunities before implementation, not during.
### Choosing the right workflow by scope
| Scope | Workflow |
@@ -114,6 +117,9 @@ Speckit manages **what** to build (specs as living documents). RPI manages **how
- `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
- `specs/006-undo-redo/` — undo/redo for encounter state mutations
- `specs/007-json-import-export/` — JSON import/export for full encounter state (encounter, undo/redo, player characters)
- `specs/008-encounter-difficulty/` — Live encounter difficulty indicator (5.5e XP budget system), optional PC level field
## Constitution (key principles)
@@ -124,10 +130,3 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
3. **Clarification-First** — Ask before making non-trivial assumptions.
4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans.
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.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
- 005-player-characters: Added TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Vite 6, Tailwind CSS v4, Lucide Reac
+76 -7
View File
@@ -1,4 +1,4 @@
# Encounter Console
# Initiative
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.
@@ -7,7 +7,10 @@ A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e
- **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
- **Player characters** — create reusable player character templates with name, AC, HP, level, color, and icon; search and add them to encounters with pre-filled stats; manage (edit/delete) from a dedicated panel
- **Encounter difficulty** — live 3-bar indicator in the top bar showing encounter difficulty (Trivial/Low/Moderate/High) based on the 2024 5.5e XP budget system; automatically derived from PC levels and bestiary creature CRs
- **Undo/redo** — reverse any encounter action with Undo/Redo buttons or keyboard shortcuts (Ctrl+Z / Ctrl+Shift+Z, Cmd on Mac); history persists across page reloads
- **Import/export** — export the full encounter state (combatants, undo/redo history, player characters) as a JSON file or copy to clipboard; import from file upload or pasted JSON with validation and confirmation
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB; player characters stored independently
## Prerequisites
@@ -31,16 +34,42 @@ Open `http://localhost:5173`.
| `pnpm --filter web dev` | Start the dev server |
| `pnpm --filter web build` | Production build |
| `pnpm test` | Run all tests (Vitest) |
| `pnpm check` | Full merge gate (knip, biome, typecheck, test, jscpd) |
| `pnpm test:watch` | Tests in watch mode |
| `pnpm vitest run path/to/test.ts` | Run a single test file |
| `pnpm typecheck` | TypeScript type checking |
| `pnpm lint` | Biome lint |
| `pnpm format` | Biome format (writes changes) |
| `pnpm check` | Full merge gate (see below) |
### Merge gate (`pnpm check`)
All of these run at pre-commit via Lefthook (in parallel where possible):
- `pnpm audit` — security audit
- `knip` — unused code detection
- `biome check` — formatting + linting
- `oxlint` — type-aware linting (complements Biome)
- Custom scripts — lint-ignore caps, className enforcement, component prop limits
- `tsc --build` — TypeScript strict mode
- `vitest run` — tests with per-path coverage thresholds
- `jscpd` + `jsinspect` — copy-paste and structural duplication detection
## Tech Stack
- TypeScript 5.8 (strict mode), React 19, Vite 6
- Tailwind CSS v4 (dark/light theme)
- Biome 2.4 (formatting + linting), oxlint (type-aware linting)
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
- Knip (unused code), jscpd + jsinspect (duplication detection)
## 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)
packages/application/ Use cases — orchestrates domain via port interfaces
data/bestiary/ Pre-built bestiary search index (~10k creatures)
scripts/ Build tooling (layer checks, index generation)
specs/ Feature specifications (spec → plan → tasks)
```
@@ -52,5 +81,45 @@ 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.
- **Domain** — pure functions, no I/O, no randomness, no framework imports. Errors returned as values (`DomainError`), never thrown.
- **Application** — orchestrates domain calls via port interfaces (`EncounterStore`, `PlayerCharacterStore`, etc.). No business logic.
- **Web** — React adapter. Implements ports using hooks/state. All UI components, persistence, and external data access live here.
Layer boundaries are enforced by automated import checks that run as part of the test suite.
## Contributing
### Workflow
Development is spec-driven. Feature specs live in `specs/NNN-feature-name/` and are managed through Claude Code skills (see [CLAUDE.md](./CLAUDE.md) for full details).
| Scope | What to do |
|-------|-----------|
| Bug fix / CSS tweak | Fix it, run `pnpm check`, commit. Optionally use `/browser-interactive-testing` for visual verification. |
| Change to existing feature | Update the feature spec, then implement |
| Larger change to existing feature | Update the spec → `/rpi-research``/rpi-plan``/rpi-implement` |
| New feature | `/speckit.specify``/speckit.clarify``/speckit.plan``/speckit.tasks``/speckit.implement` |
Use `/write-issue` to create well-structured Gitea issues, and `/integrate-issue` to pull an existing issue's requirements into the relevant feature spec.
### Before committing
Run `pnpm check` — Lefthook runs this automatically at pre-commit, but running it manually first saves time. All checks must pass.
### Conventions
- **Biome** for formatting and linting — tab indentation, 80-char lines
- **TypeScript strict mode** with `verbatimModuleSyntax` (type-only imports must use `import type`)
- **Max 8 props** per component interface — use React context for shared state
- **Tests** in `__tests__/` directories — test pure functions directly, use `renderHook` for hooks
See [CLAUDE.md](./CLAUDE.md) for the full conventions and project constitution.
## Bestiary Index
The bestiary search index (`data/bestiary/index.json`) is pre-built and checked into the repo. To regenerate it (e.g., after a new source book release):
1. Clone [5etools-mirror-3/5etools-src](https://github.com/5etools-mirror-3/5etools-src) locally
2. Run `node scripts/generate-bestiary-index.mjs /path/to/5etools-src`
The script extracts creature names, stats, and source info into a compact search index.
+10 -1
View File
@@ -2,7 +2,16 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0e1a2e" />
<link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta property="og:title" content="Initiative Tracker" />
<meta property="og:description" content="D&D combat initiative tracker" />
<meta property="og:image" content="https://initiative.dostulata.rocks/icon-512.png" />
<meta property="og:url" content="https://initiative.dostulata.rocks/" />
<meta property="og:type" content="website" />
<title>Initiative Tracker</title>
</head>
<body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<defs>
<linearGradient id="f" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#60a5fa"/>
<stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
</defs>
<g transform="translate(16 15) scale(1.55)" fill="none" stroke="url(#f)" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76" fill="url(#f)" fill-opacity="0.15"/>
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49" fill="url(#f)" fill-opacity="0.25"/>
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44" fill="url(#f)" fill-opacity="0.2"/>
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44" fill="url(#f)" fill-opacity="0.1"/>
<line x1="-4.75" y1="-2.74" x2="-8.19" y2="-4.74"/>
<line x1="8.18" y1="-4.74" x2="4.75" y2="-2.74"/>
<line x1="-0.01" y1="5.44" x2="-0.01" y2="9.51"/>
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76"/>
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49"/>
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44"/>
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+31
View File
@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<defs>
<radialGradient id="bg" cx="50%" cy="40%" r="70%">
<stop offset="0%" stop-color="#1a2e4a"/>
<stop offset="100%" stop-color="#0e1a2e"/>
</radialGradient>
<linearGradient id="d20fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#60a5fa"/>
<stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
<linearGradient id="d20stroke" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#93c5fd"/>
<stop offset="100%" stop-color="#3b82f6"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
<g transform="translate(256 256) scale(8.5)" fill="none" stroke="url(#d20stroke)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76" fill="url(#d20fill)" fill-opacity="0.15"/>
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49" fill="url(#d20fill)" fill-opacity="0.25"/>
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44" fill="url(#d20fill)" fill-opacity="0.2"/>
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44" fill="url(#d20fill)" fill-opacity="0.1"/>
<line x1="-4.75" y1="-2.74" x2="-8.19" y2="-4.74"/>
<line x1="8.18" y1="-4.74" x2="4.75" y2="-2.74"/>
<line x1="-0.01" y1="5.44" x2="-0.01" y2="9.51"/>
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76"/>
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49"/>
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44"/>
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44"/>
</g>
<text x="256" y="278" text-anchor="middle" dominant-baseline="central" font-family="Inter, system-ui, sans-serif" font-weight="700" font-size="52" fill="#93c5fd" letter-spacing="1">20</text>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+21
View File
@@ -0,0 +1,21 @@
{
"name": "Initiative Tracker",
"short_name": "Initiative",
"description": "D&D combat initiative tracker",
"start_url": "/",
"display": "standalone",
"background_color": "#0e1a2e",
"theme_color": "#0e1a2e",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+25 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { ActionBar } from "./components/action-bar.js";
import { BulkImportToasts } from "./components/bulk-import-toasts.js";
import { CombatantRow } from "./components/combatant-row.js";
@@ -6,6 +6,7 @@ import {
PlayerCharacterSection,
type PlayerCharacterSectionHandle,
} from "./components/player-character-section.js";
import { SettingsModal } from "./components/settings-modal.js";
import { StatBlockPanel } from "./components/stat-block-panel.js";
import { Toast } from "./components/toast.js";
import { TurnNavigation } from "./components/turn-navigation.js";
@@ -23,11 +24,19 @@ export function App() {
useAutoStatBlock();
const [settingsOpen, setSettingsOpen] = useState(false);
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
const actionBarInputRef = useRef<HTMLInputElement>(null);
const activeRowRef = useRef<HTMLDivElement>(null);
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
// Close the side panel when the encounter becomes empty
useEffect(() => {
if (isEmpty) {
sidePanel.dismissPanel();
}
}, [isEmpty, sidePanel.dismissPanel]);
// Auto-scroll to active combatant when turn changes
const activeIndex = encounter.activeIndex;
useEffect(() => {
@@ -41,10 +50,13 @@ export function App() {
return (
<div className="flex h-dvh flex-col">
<div className="relative mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-3 px-4">
<div className="relative mx-auto flex min-h-0 w-full flex-1 flex-col gap-3 sm:max-w-2xl sm:px-4">
{!!actionBarAnim.showTopBar && (
<div
className={cn("shrink-0 pt-8", actionBarAnim.topBarClass)}
className={cn(
"shrink-0 pt-[env(safe-area-inset-top)] sm:pt-[max(env(safe-area-inset-top),2rem)]",
actionBarAnim.topBarClass,
)}
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
>
<TurnNavigation />
@@ -62,6 +74,7 @@ export function App() {
onManagePlayers={() =>
playerCharacterRef.current?.openManagement()
}
onOpenSettings={() => setSettingsOpen(true)}
autoFocus
/>
</div>
@@ -82,7 +95,10 @@ export function App() {
</div>
<div
className={cn("shrink-0 pb-8", actionBarAnim.settlingClass)}
className={cn(
"shrink-0 pb-[env(safe-area-inset-bottom)] sm:pb-[max(env(safe-area-inset-bottom),2rem)]",
actionBarAnim.settlingClass,
)}
onAnimationEnd={actionBarAnim.onSettleEnd}
>
<ActionBar
@@ -90,6 +106,7 @@ export function App() {
onManagePlayers={() =>
playerCharacterRef.current?.openManagement()
}
onOpenSettings={() => setSettingsOpen(true)}
/>
</div>
</>
@@ -120,6 +137,10 @@ export function App() {
/>
)}
<SettingsModal
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
/>
<PlayerCharacterSection ref={playerCharacterRef} />
</div>
);
@@ -0,0 +1,233 @@
import {
combatantId,
type Encounter,
type ExportBundle,
type PlayerCharacter,
playerCharacterId,
type UndoRedoState,
} from "@initiative/domain";
import { describe, expect, it } from "vitest";
import {
assembleExportBundle,
bundleToJson,
resolveFilename,
validateImportBundle,
} from "../persistence/export-import.js";
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
const DEFAULT_FILENAME_RE = /^initiative-export-\d{4}-\d{2}-\d{2}\.json$/;
const encounter: Encounter = {
combatants: [
{
id: combatantId("c-1"),
name: "Goblin",
initiative: 15,
maxHp: 7,
currentHp: 7,
ac: 15,
},
{
id: combatantId("c-2"),
name: "Aria",
initiative: 18,
maxHp: 45,
currentHp: 40,
ac: 16,
color: "blue",
icon: "sword",
playerCharacterId: playerCharacterId("pc-1"),
},
],
activeIndex: 0,
roundNumber: 2,
};
const undoRedoState: UndoRedoState = {
undoStack: [
{
combatants: [{ id: combatantId("c-1"), name: "Goblin", initiative: 15 }],
activeIndex: 0,
roundNumber: 1,
},
],
redoStack: [],
};
const playerCharacters: PlayerCharacter[] = [
{
id: playerCharacterId("pc-1"),
name: "Aria",
ac: 16,
maxHp: 45,
color: "blue",
icon: "sword",
},
];
describe("assembleExportBundle", () => {
it("returns a bundle with version 1", () => {
const bundle = assembleExportBundle(
encounter,
undoRedoState,
playerCharacters,
);
expect(bundle.version).toBe(1);
});
it("includes an ISO timestamp", () => {
const bundle = assembleExportBundle(
encounter,
undoRedoState,
playerCharacters,
);
expect(bundle.exportedAt).toMatch(ISO_TIMESTAMP_RE);
});
it("includes the encounter", () => {
const bundle = assembleExportBundle(
encounter,
undoRedoState,
playerCharacters,
);
expect(bundle.encounter).toEqual(encounter);
});
it("includes undo and redo stacks", () => {
const bundle = assembleExportBundle(
encounter,
undoRedoState,
playerCharacters,
);
expect(bundle.undoStack).toEqual(undoRedoState.undoStack);
expect(bundle.redoStack).toEqual(undoRedoState.redoStack);
});
it("includes player characters", () => {
const bundle = assembleExportBundle(
encounter,
undoRedoState,
playerCharacters,
);
expect(bundle.playerCharacters).toEqual(playerCharacters);
});
});
describe("assembleExportBundle with includeHistory", () => {
it("excludes undo/redo stacks when includeHistory is false", () => {
const bundle = assembleExportBundle(
encounter,
undoRedoState,
playerCharacters,
false,
);
expect(bundle.undoStack).toHaveLength(0);
expect(bundle.redoStack).toHaveLength(0);
});
it("includes undo/redo stacks when includeHistory is true", () => {
const bundle = assembleExportBundle(
encounter,
undoRedoState,
playerCharacters,
true,
);
expect(bundle.undoStack).toEqual(undoRedoState.undoStack);
expect(bundle.redoStack).toEqual(undoRedoState.redoStack);
});
it("includes undo/redo stacks by default", () => {
const bundle = assembleExportBundle(
encounter,
undoRedoState,
playerCharacters,
);
expect(bundle.undoStack).toEqual(undoRedoState.undoStack);
});
});
describe("bundleToJson", () => {
it("produces valid JSON that round-trips through validateImportBundle", () => {
const bundle = assembleExportBundle(
encounter,
undoRedoState,
playerCharacters,
);
const json = bundleToJson(bundle);
const parsed: unknown = JSON.parse(json);
const result = validateImportBundle(parsed);
expect(typeof result).toBe("object");
});
});
describe("resolveFilename", () => {
it("uses date-based default when no name provided", () => {
const result = resolveFilename();
expect(result).toMatch(DEFAULT_FILENAME_RE);
});
it("uses date-based default for empty string", () => {
const result = resolveFilename("");
expect(result).toMatch(DEFAULT_FILENAME_RE);
});
it("uses date-based default for whitespace-only string", () => {
const result = resolveFilename(" ");
expect(result).toMatch(DEFAULT_FILENAME_RE);
});
it("appends .json to a custom name", () => {
expect(resolveFilename("my-encounter")).toBe("my-encounter.json");
});
it("does not double-append .json", () => {
expect(resolveFilename("my-encounter.json")).toBe("my-encounter.json");
});
it("trims whitespace from custom name", () => {
expect(resolveFilename(" my-encounter ")).toBe("my-encounter.json");
});
});
describe("round-trip: export then import", () => {
it("produces identical state after round-trip", () => {
const bundle = assembleExportBundle(
encounter,
undoRedoState,
playerCharacters,
);
const serialized = JSON.parse(JSON.stringify(bundle));
const result = validateImportBundle(serialized);
expect(typeof result).toBe("object");
const imported = result as ExportBundle;
expect(imported.version).toBe(bundle.version);
expect(imported.encounter).toEqual(bundle.encounter);
expect(imported.undoStack).toEqual(bundle.undoStack);
expect(imported.redoStack).toEqual(bundle.redoStack);
expect(imported.playerCharacters).toEqual(bundle.playerCharacters);
});
it("round-trips an empty encounter", () => {
const emptyEncounter: Encounter = {
combatants: [],
activeIndex: 0,
roundNumber: 1,
};
const emptyUndoRedo: UndoRedoState = {
undoStack: [],
redoStack: [],
};
const bundle = assembleExportBundle(emptyEncounter, emptyUndoRedo, []);
const serialized = JSON.parse(JSON.stringify(bundle));
const result = validateImportBundle(serialized);
expect(typeof result).toBe("object");
const imported = result as ExportBundle;
expect(imported.encounter.combatants).toHaveLength(0);
expect(imported.undoStack).toHaveLength(0);
expect(imported.redoStack).toHaveLength(0);
expect(imported.playerCharacters).toHaveLength(0);
});
});
@@ -5,6 +5,7 @@ import {
EncounterProvider,
InitiativeRollsProvider,
PlayerCharactersProvider,
RulesEditionProvider,
SidePanelProvider,
ThemeProvider,
} from "../contexts/index.js";
@@ -12,6 +13,7 @@ import {
export function AllProviders({ children }: { children: ReactNode }) {
return (
<ThemeProvider>
<RulesEditionProvider>
<EncounterProvider>
<BestiaryProvider>
<PlayerCharactersProvider>
@@ -23,6 +25,7 @@ export function AllProviders({ children }: { children: ReactNode }) {
</PlayerCharactersProvider>
</BestiaryProvider>
</EncounterProvider>
</RulesEditionProvider>
</ThemeProvider>
);
}
@@ -0,0 +1,249 @@
import type { ExportBundle } from "@initiative/domain";
import { describe, expect, it } from "vitest";
import { validateImportBundle } from "../persistence/export-import.js";
function validBundle(): Record<string, unknown> {
return {
version: 1,
exportedAt: "2026-03-27T12:00:00.000Z",
encounter: {
combatants: [{ id: "c-1", name: "Goblin", initiative: 15 }],
activeIndex: 0,
roundNumber: 1,
},
undoStack: [],
redoStack: [],
playerCharacters: [],
};
}
describe("validateImportBundle", () => {
it("accepts a valid bundle", () => {
const result = validateImportBundle(validBundle());
expect(typeof result).toBe("object");
const bundle = result as ExportBundle;
expect(bundle.version).toBe(1);
expect(bundle.encounter.combatants).toHaveLength(1);
expect(bundle.encounter.combatants[0].name).toBe("Goblin");
});
it("accepts a valid bundle with empty encounter", () => {
const input = {
...validBundle(),
encounter: { combatants: [], activeIndex: 0, roundNumber: 1 },
};
const result = validateImportBundle(input);
expect(typeof result).toBe("object");
const bundle = result as ExportBundle;
expect(bundle.encounter.combatants).toHaveLength(0);
});
it("accepts a bundle with undo/redo stacks", () => {
const enc = {
combatants: [{ id: "c-1", name: "Orc" }],
activeIndex: 0,
roundNumber: 1,
};
const input = {
...validBundle(),
undoStack: [enc],
redoStack: [enc],
};
const result = validateImportBundle(input);
expect(typeof result).toBe("object");
const bundle = result as ExportBundle;
expect(bundle.undoStack).toHaveLength(1);
expect(bundle.redoStack).toHaveLength(1);
});
it("accepts a bundle with player characters", () => {
const input = {
...validBundle(),
playerCharacters: [
{
id: "pc-1",
name: "Aria",
ac: 16,
maxHp: 45,
color: "blue",
icon: "sword",
},
],
};
const result = validateImportBundle(input);
expect(typeof result).toBe("object");
const bundle = result as ExportBundle;
expect(bundle.playerCharacters).toHaveLength(1);
expect(bundle.playerCharacters[0].name).toBe("Aria");
});
it("rejects non-object input", () => {
expect(validateImportBundle(null)).toBe("Invalid file format");
expect(validateImportBundle(42)).toBe("Invalid file format");
expect(validateImportBundle("string")).toBe("Invalid file format");
expect(validateImportBundle([])).toBe("Invalid file format");
expect(validateImportBundle(undefined)).toBe("Invalid file format");
});
it("rejects missing version field", () => {
const input = validBundle();
delete input.version;
expect(validateImportBundle(input)).toBe("Invalid file format");
});
it("rejects version 0 or negative", () => {
expect(validateImportBundle({ ...validBundle(), version: 0 })).toBe(
"Invalid file format",
);
expect(validateImportBundle({ ...validBundle(), version: -1 })).toBe(
"Invalid file format",
);
});
it("rejects unknown version", () => {
expect(validateImportBundle({ ...validBundle(), version: 99 })).toBe(
"Invalid file format",
);
});
it("rejects missing encounter field", () => {
const input = validBundle();
delete input.encounter;
expect(validateImportBundle(input)).toBe("Invalid encounter data");
});
it("rejects invalid encounter data", () => {
expect(
validateImportBundle({ ...validBundle(), encounter: "not an object" }),
).toBe("Invalid encounter data");
});
it("rejects missing undoStack", () => {
const input = validBundle();
delete input.undoStack;
expect(validateImportBundle(input)).toBe("Invalid file format");
});
it("rejects missing redoStack", () => {
const input = validBundle();
delete input.redoStack;
expect(validateImportBundle(input)).toBe("Invalid file format");
});
it("rejects missing playerCharacters", () => {
const input = validBundle();
delete input.playerCharacters;
expect(validateImportBundle(input)).toBe("Invalid file format");
});
it("rejects non-string exportedAt", () => {
expect(validateImportBundle({ ...validBundle(), exportedAt: 12345 })).toBe(
"Invalid file format",
);
});
it("drops invalid entries from undo stack", () => {
const valid = {
combatants: [{ id: "c-1", name: "Orc" }],
activeIndex: 0,
roundNumber: 1,
};
const input = {
...validBundle(),
undoStack: [valid, "invalid", { bad: true }, valid],
};
const result = validateImportBundle(input);
expect(typeof result).toBe("object");
const bundle = result as ExportBundle;
expect(bundle.undoStack).toHaveLength(2);
});
it("drops invalid player characters", () => {
const input = {
...validBundle(),
playerCharacters: [
{ id: "pc-1", name: "Valid", ac: 10, maxHp: 20 },
{ id: "", name: "Bad ID" },
"not an object",
{ id: "pc-3", name: "Also Valid", ac: 15, maxHp: 30 },
],
};
const result = validateImportBundle(input);
expect(typeof result).toBe("object");
const bundle = result as ExportBundle;
expect(bundle.playerCharacters).toHaveLength(2);
});
it("rejects JSON array instead of object", () => {
expect(validateImportBundle([1, 2, 3])).toBe("Invalid file format");
});
it("rejects encounter that fails rehydration (missing combatant fields)", () => {
const input = {
...validBundle(),
encounter: {
combatants: [{ noId: true }],
activeIndex: 0,
roundNumber: 1,
},
};
expect(validateImportBundle(input)).toBe("Invalid encounter data");
});
it("strips invalid color/icon from player characters but keeps the character", () => {
const input = {
...validBundle(),
playerCharacters: [
{
id: "pc-1",
name: "Test",
ac: 10,
maxHp: 20,
color: "neon-pink",
icon: "bazooka",
},
],
};
const result = validateImportBundle(input);
// rehydrateCharacter rejects characters with invalid color/icon members
// that are not in the valid sets, so this character is dropped
expect(typeof result).toBe("object");
const bundle = result as ExportBundle;
expect(bundle.playerCharacters).toHaveLength(0);
});
it("keeps player characters with valid optional color and icon", () => {
const input = {
...validBundle(),
playerCharacters: [
{
id: "pc-1",
name: "Aria",
ac: 16,
maxHp: 45,
color: "blue",
icon: "sword",
},
],
};
const result = validateImportBundle(input);
expect(typeof result).toBe("object");
const bundle = result as ExportBundle;
expect(bundle.playerCharacters).toHaveLength(1);
expect(bundle.playerCharacters[0].color).toBe("blue");
expect(bundle.playerCharacters[0].icon).toBe("sword");
});
it("ignores unknown extra fields on the bundle", () => {
const input = {
...validBundle(),
unknownField: "should be ignored",
anotherExtra: 42,
};
const result = validateImportBundle(input);
expect(typeof result).toBe("object");
const bundle = result as ExportBundle;
expect(bundle.version).toBe(1);
expect("unknownField" in bundle).toBe(false);
});
});
@@ -300,6 +300,44 @@ describe("normalizeBestiary", () => {
expect(creatures[0].proficiencyBonus).toBe(6);
});
it("normalizes pre-2024 {@atk mw} tags to full attack type text", () => {
const raw = {
monster: [
{
name: "Adult Black Dragon",
source: "MM",
size: ["H"],
type: "dragon",
ac: [19],
hp: { average: 195, formula: "17d12 + 85" },
speed: { walk: 40, fly: 80, swim: 40 },
str: 23,
dex: 14,
con: 21,
int: 14,
wis: 13,
cha: 17,
passive: 21,
cr: "14",
action: [
{
name: "Bite",
entries: [
"{@atk mw} {@hit 11} to hit, reach 10 ft., one target. {@h}17 ({@damage 2d10 + 6}) piercing damage plus 4 ({@damage 1d8}) acid damage.",
],
},
],
},
],
};
const creatures = normalizeBestiary(raw);
const bite = creatures[0].actions?.[0];
expect(bite?.text).toContain("Melee Weapon Attack:");
expect(bite?.text).not.toContain("mw");
expect(bite?.text).not.toContain("{@");
});
it("handles fly speed with hover condition", () => {
const raw = {
monster: [
@@ -50,6 +50,26 @@ describe("stripTags", () => {
expect(stripTags("{@atkr m,r}")).toBe("Melee or Ranged Attack Roll:");
});
it("strips {@atk mw} to Melee Weapon Attack:", () => {
expect(stripTags("{@atk mw}")).toBe("Melee Weapon Attack:");
});
it("strips {@atk rw} to Ranged Weapon Attack:", () => {
expect(stripTags("{@atk rw}")).toBe("Ranged Weapon Attack:");
});
it("strips {@atk ms} to Melee Spell Attack:", () => {
expect(stripTags("{@atk ms}")).toBe("Melee Spell Attack:");
});
it("strips {@atk rs} to Ranged Spell Attack:", () => {
expect(stripTags("{@atk rs}")).toBe("Ranged Spell Attack:");
});
it("strips {@atk mw,rw} to Melee or Ranged Weapon Attack:", () => {
expect(stripTags("{@atk mw,rw}")).toBe("Melee or Ranged Weapon Attack:");
});
it("strips {@recharge 5} to (Recharge 5-6)", () => {
expect(stripTags("{@recharge 5}")).toBe("(Recharge 5-6)");
});
+7 -3
View File
@@ -3,7 +3,7 @@ import { type IDBPDatabase, openDB } from "idb";
const DB_NAME = "initiative-bestiary";
const STORE_NAME = "sources";
const DB_VERSION = 1;
const DB_VERSION = 2;
export interface CachedSourceInfo {
readonly sourceCode: string;
@@ -32,12 +32,16 @@ async function getDb(): Promise<IDBPDatabase | null> {
try {
db = await openDB(DB_NAME, DB_VERSION, {
upgrade(database) {
if (!database.objectStoreNames.contains(STORE_NAME)) {
upgrade(database, oldVersion, _newVersion, transaction) {
if (oldVersion < 1) {
database.createObjectStore(STORE_NAME, {
keyPath: "sourceCode",
});
}
if (oldVersion < 2 && database.objectStoreNames.contains(STORE_NAME)) {
// Clear cached creatures to pick up improved tag processing
transaction.objectStore(STORE_NAME).clear();
}
},
});
return db;
+15 -1
View File
@@ -14,6 +14,15 @@ const ATKR_MAP: Record<string, string> = {
"r,m": "Melee or Ranged Attack Roll:",
};
const ATK_MAP: Record<string, string> = {
mw: "Melee Weapon Attack:",
rw: "Ranged Weapon Attack:",
ms: "Melee Spell Attack:",
rs: "Ranged Spell Attack:",
"mw,rw": "Melee or Ranged Weapon Attack:",
"rw,mw": "Melee or Ranged Weapon Attack:",
};
/**
* Strips 5etools {@tag ...} markup from text, converting to plain readable text.
*
@@ -51,11 +60,16 @@ export function stripTags(text: string): string {
// {@hit N} → "+N"
result = result.replaceAll(/\{@hit\s+(\d+)\}/g, "+$1");
// {@atkr type} → mapped attack roll text
// {@atkr type} → mapped attack roll text (2024 rules)
result = result.replaceAll(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
return ATKR_MAP[type.trim()] ?? "Attack Roll:";
});
// {@atk type} → mapped attack type text (pre-2024 data)
result = result.replaceAll(/\{@atk\s+([^}]+)\}/g, (_, type: string) => {
return ATK_MAP[type.trim()] ?? "Attack:";
});
// {@actSave ability} → "Ability saving throw"
result = result.replaceAll(
/\{@actSave\s+([^}]+)\}/g,
@@ -50,6 +50,16 @@ beforeAll(() => {
dispatchEvent: vi.fn(),
})),
});
HTMLDialogElement.prototype.showModal =
HTMLDialogElement.prototype.showModal ||
function showModal(this: HTMLDialogElement) {
this.setAttribute("open", "");
};
HTMLDialogElement.prototype.close =
HTMLDialogElement.prototype.close ||
function close(this: HTMLDialogElement) {
this.removeAttribute("open");
};
});
afterEach(cleanup);
@@ -118,4 +128,61 @@ describe("ActionBar", () => {
screen.getByRole("button", { name: "More actions" }),
).toBeInTheDocument();
});
it("opens export method dialog via overflow menu", async () => {
const user = userEvent.setup();
renderBar();
await user.click(screen.getByRole("button", { name: "More actions" }));
// Click the menu item
const items = screen.getAllByText("Export Encounter");
await user.click(items[0]);
// Dialog should now be open — it renders a second "Export Encounter" as heading
expect(
screen.getAllByText("Export Encounter").length,
).toBeGreaterThanOrEqual(1);
});
it("opens import method dialog via overflow menu", async () => {
const user = userEvent.setup();
renderBar();
await user.click(screen.getByRole("button", { name: "More actions" }));
const items = screen.getAllByText("Import Encounter");
await user.click(items[0]);
expect(
screen.getAllByText("Import Encounter").length,
).toBeGreaterThanOrEqual(1);
});
it("calls onManagePlayers from overflow menu", async () => {
const onManagePlayers = vi.fn();
const user = userEvent.setup();
renderBar({ onManagePlayers });
await user.click(screen.getByRole("button", { name: "More actions" }));
await user.click(screen.getByText("Player Characters"));
expect(onManagePlayers).toHaveBeenCalledOnce();
});
it("calls onOpenSettings from overflow menu", async () => {
const onOpenSettings = vi.fn();
const user = userEvent.setup();
renderBar({ onOpenSettings });
await user.click(screen.getByRole("button", { name: "More actions" }));
await user.click(screen.getByText("Settings"));
expect(onOpenSettings).toHaveBeenCalledOnce();
});
it("submits custom stats with combatant", async () => {
const user = userEvent.setup();
renderBar();
const input = screen.getByPlaceholderText("+ Add combatants");
await user.type(input, "Fighter");
const initInput = screen.getByPlaceholderText("Init");
const acInput = screen.getByPlaceholderText("AC");
const hpInput = screen.getByPlaceholderText("MaxHP");
await user.type(initInput, "15");
await user.type(acInput, "18");
await user.type(hpInput, "45");
await user.click(screen.getByRole("button", { name: "Add" }));
expect(input).toHaveValue("");
});
});
@@ -6,6 +6,7 @@ import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createRef, type RefObject } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RulesEditionProvider } from "../../contexts/index.js";
import { ConditionPicker } from "../condition-picker";
afterEach(cleanup);
@@ -24,12 +25,14 @@ function renderPicker(
document.body.appendChild(anchor);
(anchorRef as { current: HTMLElement }).current = anchor;
const result = render(
<RulesEditionProvider>
<ConditionPicker
anchorRef={anchorRef}
activeConditions={overrides.activeConditions ?? []}
onToggle={onToggle}
onClose={onClose}
/>,
/>
</RulesEditionProvider>,
);
return { ...result, onToggle, onClose };
}
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
import type { ConditionId } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ConditionTags } from "../condition-tags.js";
vi.mock("../../contexts/rules-edition-context.js", () => ({
useRulesEditionContext: () => ({ edition: "5.5e" }),
}));
afterEach(cleanup);
describe("ConditionTags", () => {
it("renders nothing when conditions is undefined", () => {
const { container } = render(
<ConditionTags
conditions={undefined}
onRemove={() => {}}
onOpenPicker={() => {}}
/>,
);
// Only the add button should be present
expect(container.querySelectorAll("button")).toHaveLength(1);
});
it("renders a button per condition", () => {
const conditions: ConditionId[] = ["blinded", "prone"];
render(
<ConditionTags
conditions={conditions}
onRemove={() => {}}
onOpenPicker={() => {}}
/>,
);
expect(
screen.getByRole("button", { name: "Remove Blinded" }),
).toBeDefined();
expect(screen.getByRole("button", { name: "Remove Prone" })).toBeDefined();
});
it("calls onRemove with condition id when clicked", async () => {
const onRemove = vi.fn();
render(
<ConditionTags
conditions={["blinded"] as ConditionId[]}
onRemove={onRemove}
onOpenPicker={() => {}}
/>,
);
await userEvent.click(
screen.getByRole("button", { name: "Remove Blinded" }),
);
expect(onRemove).toHaveBeenCalledWith("blinded");
});
it("calls onOpenPicker when add button is clicked", async () => {
const onOpenPicker = vi.fn();
render(
<ConditionTags
conditions={[]}
onRemove={() => {}}
onOpenPicker={onOpenPicker}
/>,
);
await userEvent.click(
screen.getByRole("button", { name: "Add condition" }),
);
expect(onOpenPicker).toHaveBeenCalledOnce();
});
it("renders empty conditions array without errors", () => {
render(
<ConditionTags
conditions={[]}
onRemove={() => {}}
onOpenPicker={() => {}}
/>,
);
// Only add button
expect(screen.getByRole("button", { name: "Add condition" })).toBeDefined();
});
});
@@ -0,0 +1,173 @@
// @vitest-environment jsdom
import type { PlayerCharacter } from "@initiative/domain";
import { playerCharacterId } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { CreatePlayerModal } from "../create-player-modal.js";
beforeAll(() => {
HTMLDialogElement.prototype.showModal =
HTMLDialogElement.prototype.showModal ||
function showModal(this: HTMLDialogElement) {
this.setAttribute("open", "");
};
HTMLDialogElement.prototype.close =
HTMLDialogElement.prototype.close ||
function close(this: HTMLDialogElement) {
this.removeAttribute("open");
};
});
afterEach(cleanup);
function renderModal(
overrides: Partial<Parameters<typeof CreatePlayerModal>[0]> = {},
) {
const defaults = {
open: true,
onClose: vi.fn(),
onSave: vi.fn(),
};
const props = { ...defaults, ...overrides };
return { ...render(<CreatePlayerModal {...props} />), ...props };
}
describe("CreatePlayerModal", () => {
it("renders create form with defaults", () => {
renderModal();
expect(screen.getByText("Create Player")).toBeDefined();
expect(screen.getByLabelText("Name")).toBeDefined();
expect(screen.getByLabelText("AC")).toBeDefined();
expect(screen.getByLabelText("Max HP")).toBeDefined();
expect(screen.getByLabelText("Level")).toBeDefined();
expect(screen.getByRole("button", { name: "Create" })).toBeDefined();
});
it("renders edit form when playerCharacter is provided", () => {
const pc: PlayerCharacter = {
id: playerCharacterId("pc-1"),
name: "Gandalf",
ac: 15,
maxHp: 40,
color: "blue",
icon: "wand",
level: 10,
};
renderModal({ playerCharacter: pc });
expect(screen.getByText("Edit Player")).toBeDefined();
expect(screen.getByLabelText("Name")).toHaveProperty("value", "Gandalf");
expect(screen.getByLabelText("AC")).toHaveProperty("value", "15");
expect(screen.getByLabelText("Max HP")).toHaveProperty("value", "40");
expect(screen.getByLabelText("Level")).toHaveProperty("value", "10");
expect(screen.getByRole("button", { name: "Save" })).toBeDefined();
});
it("calls onSave with valid data", async () => {
const { onSave, onClose } = renderModal();
const user = userEvent.setup();
await user.type(screen.getByLabelText("Name"), "Aria");
await user.clear(screen.getByLabelText("AC"));
await user.type(screen.getByLabelText("AC"), "16");
await user.clear(screen.getByLabelText("Max HP"));
await user.type(screen.getByLabelText("Max HP"), "30");
await user.type(screen.getByLabelText("Level"), "5");
await user.click(screen.getByRole("button", { name: "Create" }));
expect(onSave).toHaveBeenCalledWith(
"Aria",
16,
30,
undefined,
undefined,
5,
);
expect(onClose).toHaveBeenCalled();
});
it("shows error for empty name", async () => {
const { onSave } = renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Create" }));
expect(screen.getByText("Name is required")).toBeDefined();
expect(onSave).not.toHaveBeenCalled();
});
it("shows error for invalid AC", async () => {
const { onSave } = renderModal();
const user = userEvent.setup();
await user.type(screen.getByLabelText("Name"), "Test");
await user.clear(screen.getByLabelText("AC"));
await user.type(screen.getByLabelText("AC"), "abc");
await user.click(screen.getByRole("button", { name: "Create" }));
expect(screen.getByText("AC must be a non-negative number")).toBeDefined();
expect(onSave).not.toHaveBeenCalled();
});
it("shows error for invalid Max HP", async () => {
const { onSave } = renderModal();
const user = userEvent.setup();
await user.type(screen.getByLabelText("Name"), "Test");
await user.clear(screen.getByLabelText("Max HP"));
await user.type(screen.getByLabelText("Max HP"), "0");
await user.click(screen.getByRole("button", { name: "Create" }));
expect(screen.getByText("Max HP must be at least 1")).toBeDefined();
expect(onSave).not.toHaveBeenCalled();
});
it("shows error for invalid level", async () => {
const { onSave } = renderModal();
const user = userEvent.setup();
await user.type(screen.getByLabelText("Name"), "Test");
await user.type(screen.getByLabelText("Level"), "25");
await user.click(screen.getByRole("button", { name: "Create" }));
expect(screen.getByText("Level must be between 1 and 20")).toBeDefined();
expect(onSave).not.toHaveBeenCalled();
});
it("clears error when name is edited", async () => {
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Create" }));
expect(screen.getByText("Name is required")).toBeDefined();
await user.type(screen.getByLabelText("Name"), "A");
expect(screen.queryByText("Name is required")).toBeNull();
});
it("calls onClose when cancel is clicked", async () => {
const { onClose } = renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(onClose).toHaveBeenCalledOnce();
});
it("omits level when field is empty", async () => {
const { onSave } = renderModal();
const user = userEvent.setup();
await user.type(screen.getByLabelText("Name"), "Aria");
await user.click(screen.getByRole("button", { name: "Create" }));
expect(onSave).toHaveBeenCalledWith(
"Aria",
10,
10,
undefined,
undefined,
undefined,
);
});
});
@@ -0,0 +1,71 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { Dialog, DialogHeader } from "../ui/dialog.js";
beforeAll(() => {
HTMLDialogElement.prototype.showModal =
HTMLDialogElement.prototype.showModal ||
function showModal(this: HTMLDialogElement) {
this.setAttribute("open", "");
};
HTMLDialogElement.prototype.close =
HTMLDialogElement.prototype.close ||
function close(this: HTMLDialogElement) {
this.removeAttribute("open");
};
});
afterEach(cleanup);
describe("Dialog", () => {
it("opens when open=true", () => {
render(
<Dialog open={true} onClose={() => {}}>
Content
</Dialog>,
);
expect(screen.getByText("Content")).toBeDefined();
});
it("closes when open changes from true to false", () => {
const { rerender } = render(
<Dialog open={true} onClose={() => {}}>
Content
</Dialog>,
);
const dialog = document.querySelector("dialog");
expect(dialog?.hasAttribute("open")).toBe(true);
rerender(
<Dialog open={false} onClose={() => {}}>
Content
</Dialog>,
);
expect(dialog?.hasAttribute("open")).toBe(false);
});
it("calls onClose on cancel event", () => {
const onClose = vi.fn();
render(
<Dialog open={true} onClose={onClose}>
Content
</Dialog>,
);
const dialog = document.querySelector("dialog");
dialog?.dispatchEvent(new Event("cancel"));
expect(onClose).toHaveBeenCalledOnce();
});
});
describe("DialogHeader", () => {
it("renders title and close button", async () => {
const onClose = vi.fn();
render(<DialogHeader title="Test Title" onClose={onClose} />);
expect(screen.getByText("Test Title")).toBeDefined();
await userEvent.click(screen.getByRole("button"));
expect(onClose).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,59 @@
// @vitest-environment jsdom
import type { DifficultyResult } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { DifficultyIndicator } from "../difficulty-indicator.js";
afterEach(cleanup);
function makeResult(tier: DifficultyResult["tier"]): DifficultyResult {
return {
tier,
totalMonsterXp: 100,
partyBudget: { low: 50, moderate: 100, high: 200 },
};
}
describe("DifficultyIndicator", () => {
it("renders 3 bars", () => {
const { container } = render(
<DifficultyIndicator result={makeResult("moderate")} />,
);
const bars = container.querySelectorAll("[class*='rounded-sm']");
expect(bars).toHaveLength(3);
});
it("shows 'Trivial encounter difficulty' label for trivial tier", () => {
render(<DifficultyIndicator result={makeResult("trivial")} />);
expect(
screen.getByRole("img", {
name: "Trivial encounter difficulty",
}),
).toBeDefined();
});
it("shows 'Low encounter difficulty' label for low tier", () => {
render(<DifficultyIndicator result={makeResult("low")} />);
expect(
screen.getByRole("img", { name: "Low encounter difficulty" }),
).toBeDefined();
});
it("shows 'Moderate encounter difficulty' label for moderate tier", () => {
render(<DifficultyIndicator result={makeResult("moderate")} />);
expect(
screen.getByRole("img", {
name: "Moderate encounter difficulty",
}),
).toBeDefined();
});
it("shows 'High encounter difficulty' label for high tier", () => {
render(<DifficultyIndicator result={makeResult("high")} />);
expect(
screen.getByRole("img", {
name: "High encounter difficulty",
}),
).toBeDefined();
});
});
@@ -0,0 +1,89 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { Circle } from "lucide-react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { OverflowMenu } from "../ui/overflow-menu.js";
afterEach(cleanup);
const items = [
{ icon: <Circle />, label: "Action A", onClick: vi.fn() },
{ icon: <Circle />, label: "Action B", onClick: vi.fn() },
];
describe("OverflowMenu", () => {
it("renders toggle button", () => {
render(<OverflowMenu items={items} />);
expect(screen.getByRole("button", { name: "More actions" })).toBeDefined();
});
it("does not show menu items when closed", () => {
render(<OverflowMenu items={items} />);
expect(screen.queryByText("Action A")).toBeNull();
});
it("shows menu items when toggled open", async () => {
render(<OverflowMenu items={items} />);
await userEvent.click(screen.getByRole("button", { name: "More actions" }));
expect(screen.getByText("Action A")).toBeDefined();
expect(screen.getByText("Action B")).toBeDefined();
});
it("closes menu after clicking an item", async () => {
const onClick = vi.fn();
render(
<OverflowMenu items={[{ icon: <Circle />, label: "Do it", onClick }]} />,
);
await userEvent.click(screen.getByRole("button", { name: "More actions" }));
await userEvent.click(screen.getByText("Do it"));
expect(onClick).toHaveBeenCalledOnce();
expect(screen.queryByText("Do it")).toBeNull();
});
it("keeps menu open when keepOpen is true", async () => {
const onClick = vi.fn();
render(
<OverflowMenu
items={[
{
icon: <Circle />,
label: "Stay",
onClick,
keepOpen: true,
},
]}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "More actions" }));
await userEvent.click(screen.getByText("Stay"));
expect(onClick).toHaveBeenCalledOnce();
expect(screen.getByText("Stay")).toBeDefined();
});
it("disables items when disabled is true", async () => {
const onClick = vi.fn();
render(
<OverflowMenu
items={[
{
icon: <Circle />,
label: "Nope",
onClick,
disabled: true,
},
]}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "More actions" }));
const item = screen.getByText("Nope");
expect(item.closest("button")?.hasAttribute("disabled")).toBe(true);
});
});
@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RollModeMenu } from "../roll-mode-menu.js";
afterEach(cleanup);
describe("RollModeMenu", () => {
it("renders advantage and disadvantage buttons", () => {
render(
<RollModeMenu
position={{ x: 100, y: 100 }}
onSelect={() => {}}
onClose={() => {}}
/>,
);
expect(screen.getByText("Advantage")).toBeDefined();
expect(screen.getByText("Disadvantage")).toBeDefined();
});
it("calls onSelect with 'advantage' and onClose when clicked", async () => {
const onSelect = vi.fn();
const onClose = vi.fn();
render(
<RollModeMenu
position={{ x: 100, y: 100 }}
onSelect={onSelect}
onClose={onClose}
/>,
);
await userEvent.click(screen.getByText("Advantage"));
expect(onSelect).toHaveBeenCalledWith("advantage");
expect(onClose).toHaveBeenCalled();
});
it("calls onSelect with 'disadvantage' and onClose when clicked", async () => {
const onSelect = vi.fn();
const onClose = vi.fn();
render(
<RollModeMenu
position={{ x: 100, y: 100 }}
onSelect={onSelect}
onClose={onClose}
/>,
);
await userEvent.click(screen.getByText("Disadvantage"));
expect(onSelect).toHaveBeenCalledWith("disadvantage");
expect(onClose).toHaveBeenCalled();
});
});
@@ -0,0 +1,68 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Toast } from "../toast.js";
afterEach(cleanup);
describe("Toast", () => {
it("renders message text", () => {
render(<Toast message="Hello" onDismiss={() => {}} />);
expect(screen.getByText("Hello")).toBeDefined();
});
it("renders progress bar when progress is provided", () => {
render(<Toast message="Loading" progress={0.5} onDismiss={() => {}} />);
const bar = document.body.querySelector("[style*='width']") as HTMLElement;
expect(bar).not.toBeNull();
expect(bar.style.width).toBe("50%");
});
it("does not render progress bar when progress is omitted", () => {
render(<Toast message="Done" onDismiss={() => {}} />);
const bar = document.body.querySelector("[style*='width']");
expect(bar).toBeNull();
});
it("calls onDismiss when close button is clicked", async () => {
const onDismiss = vi.fn();
render(<Toast message="Hi" onDismiss={onDismiss} />);
const toast = screen.getByText("Hi").closest("div");
const button = toast?.querySelector("button");
expect(button).not.toBeNull();
await userEvent.click(button as HTMLElement);
expect(onDismiss).toHaveBeenCalledOnce();
});
describe("auto-dismiss", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("auto-dismisses after specified timeout", () => {
const onDismiss = vi.fn();
render(
<Toast message="Auto" onDismiss={onDismiss} autoDismissMs={3000} />,
);
expect(onDismiss).not.toHaveBeenCalled();
vi.advanceTimersByTime(3000);
expect(onDismiss).toHaveBeenCalledOnce();
});
it("does not auto-dismiss when autoDismissMs is omitted", () => {
const onDismiss = vi.fn();
render(<Toast message="Stay" onDismiss={onDismiss} />);
vi.advanceTimersByTime(10000);
expect(onDismiss).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,42 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { Tooltip } from "../ui/tooltip.js";
afterEach(cleanup);
describe("Tooltip", () => {
it("renders children", () => {
render(
<Tooltip content="Hint">
<button type="button">Hover me</button>
</Tooltip>,
);
expect(screen.getByText("Hover me")).toBeDefined();
});
it("does not show tooltip initially", () => {
render(
<Tooltip content="Hint">
<span>Target</span>
</Tooltip>,
);
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("shows tooltip on pointer enter and hides on pointer leave", () => {
render(
<Tooltip content="Hint text">
<span>Target</span>
</Tooltip>,
);
const wrapper = screen.getByText("Target").closest("span");
fireEvent.pointerEnter(wrapper as HTMLElement);
expect(screen.getByRole("tooltip")).toBeDefined();
expect(screen.getByText("Hint text")).toBeDefined();
fireEvent.pointerLeave(wrapper as HTMLElement);
expect(screen.queryByRole("tooltip")).toBeNull();
});
});
@@ -6,11 +6,19 @@ import { combatantId } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
// Mock the context module
// Mock the context modules
vi.mock("../../contexts/encounter-context.js", () => ({
useEncounterContext: vi.fn(),
}));
vi.mock("../../contexts/player-characters-context.js", () => ({
usePlayerCharactersContext: vi.fn().mockReturnValue({ characters: [] }),
}));
vi.mock("../../contexts/bestiary-context.js", () => ({
useBestiaryContext: vi.fn().mockReturnValue({ getCreature: () => undefined }),
}));
import { useEncounterContext } from "../../contexts/encounter-context.js";
import { TurnNavigation } from "../turn-navigation.js";
@@ -52,9 +60,19 @@ function mockContext(overrides: Partial<Encounter> = {}) {
toggleCondition: vi.fn(),
toggleConcentration: vi.fn(),
addFromBestiary: vi.fn(),
addMultipleFromBestiary: vi.fn(),
addFromPlayerCharacter: vi.fn(),
makeStore: vi.fn(),
withUndo: vi.fn((action: () => unknown) => action()),
undo: vi.fn(),
redo: vi.fn(),
canUndo: false,
canRedo: false,
undoRedoState: { undoStack: [], redoStack: [] },
setEncounter: vi.fn(),
setUndoRedoState: vi.fn(),
events: [],
lastCreatureId: null,
};
mockUseEncounterContext.mockReturnValue(
+382 -396
View File
@@ -1,53 +1,63 @@
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
import type { PlayerCharacter } from "@initiative/domain";
import {
Check,
Download,
Eye,
EyeOff,
Import,
Library,
Minus,
Monitor,
Moon,
Plus,
Sun,
Settings,
Upload,
Users,
} from "lucide-react";
import React, {
type RefObject,
useCallback,
useDeferredValue,
useState,
} from "react";
import React, { type RefObject, useCallback, useRef, useState } from "react";
import type { SearchResult } from "../contexts/bestiary-context.js";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
import { useEncounterContext } from "../contexts/encounter-context.js";
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js";
import { useThemeContext } from "../contexts/theme-context.js";
import {
creatureKey,
type QueuedCreature,
type SuggestionActions,
useActionBarState,
} from "../hooks/use-action-bar-state.js";
import { useLongPress } from "../hooks/use-long-press.js";
import { cn } from "../lib/utils.js";
import {
assembleExportBundle,
bundleToJson,
readImportFile,
triggerDownload,
validateImportBundle,
} from "../persistence/export-import.js";
import { D20Icon } from "./d20-icon.js";
import { ExportMethodDialog } from "./export-method-dialog.js";
import { ImportConfirmDialog } from "./import-confirm-prompt.js";
import { ImportMethodDialog } from "./import-method-dialog.js";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
import { RollModeMenu } from "./roll-mode-menu.js";
import { Toast } from "./toast.js";
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 {
inputRef?: RefObject<HTMLInputElement | null>;
autoFocus?: boolean;
onManagePlayers?: () => void;
onOpenSettings?: () => void;
}
function creatureKey(r: SearchResult): string {
return `${r.source}:${r.name}`;
interface AddModeSuggestionsProps {
nameInput: string;
suggestions: SearchResult[];
pcMatches: PlayerCharacter[];
suggestionIndex: number;
queued: QueuedCreature | null;
actions: SuggestionActions;
}
function AddModeSuggestions({
@@ -56,34 +66,15 @@ function AddModeSuggestions({
pcMatches,
suggestionIndex,
queued,
onDismiss,
onClickSuggestion,
onSetSuggestionIndex,
onSetQueued,
onConfirmQueued,
onAddFromPlayerCharacter,
onClear,
}: Readonly<{
nameInput: string;
suggestions: SearchResult[];
pcMatches: PlayerCharacter[];
suggestionIndex: number;
queued: QueuedCreature | null;
onDismiss: () => void;
onClear: () => void;
onClickSuggestion: (result: SearchResult) => void;
onSetSuggestionIndex: (i: number) => void;
onSetQueued: (q: QueuedCreature | null) => void;
onConfirmQueued: () => void;
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
}>) {
actions,
}: Readonly<AddModeSuggestionsProps>) {
return (
<div className="card-glow absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-lg border border-border bg-card">
<button
type="button"
className="flex w-full items-center gap-1.5 border-border border-b px-3 py-2 text-left text-accent text-sm hover:bg-accent/20"
onMouseDown={(e) => e.preventDefault()}
onClick={onDismiss}
onClick={actions.dismiss}
>
<Plus className="h-3.5 w-3.5" />
<span className="flex-1">Add "{nameInput}" as custom</span>
@@ -110,8 +101,8 @@ function AddModeSuggestions({
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onAddFromPlayerCharacter?.(pc);
onClear();
actions.addFromPlayerCharacter?.(pc);
actions.clear();
}}
>
{!!PcIcon && (
@@ -147,8 +138,8 @@ function AddModeSuggestions({
"hover:bg-hover-neutral-bg",
)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onClickSuggestion(result)}
onMouseEnter={() => onSetSuggestionIndex(i)}
onClick={() => actions.clickSuggestion(result)}
onMouseEnter={() => actions.setSuggestionIndex(i)}
>
<span>{result.name}</span>
<span className="flex items-center gap-1 text-muted-foreground text-xs">
@@ -161,9 +152,9 @@ function AddModeSuggestions({
onClick={(e) => {
e.stopPropagation();
if (queued.count <= 1) {
onSetQueued(null);
actions.setQueued(null);
} else {
onSetQueued({
actions.setQueued({
...queued,
count: queued.count - 1,
});
@@ -181,7 +172,7 @@ function AddModeSuggestions({
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
onSetQueued({
actions.setQueued({
...queued,
count: queued.count + 1,
});
@@ -195,7 +186,7 @@ function AddModeSuggestions({
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
onConfirmQueued();
actions.confirmQueued();
}}
>
<Check className="h-3.5 w-3.5" />
@@ -216,17 +207,151 @@ function AddModeSuggestions({
);
}
const THEME_ICONS = {
system: Monitor,
light: Sun,
dark: Moon,
} as const;
interface BrowseSuggestionsProps {
suggestions: SearchResult[];
suggestionIndex: number;
onSelect: (result: SearchResult) => void;
onHover: (index: number) => void;
}
const THEME_LABELS = {
system: "Theme: System",
light: "Theme: Light",
dark: "Theme: Dark",
} as const;
function BrowseSuggestions({
suggestions,
suggestionIndex,
onSelect,
onHover,
}: Readonly<BrowseSuggestionsProps>) {
if (suggestions.length === 0) return null;
return (
<div className="card-glow absolute bottom-full z-50 mb-1 w-full rounded-lg border border-border bg-card">
<ul className="max-h-48 overflow-y-auto py-1">
{suggestions.map((result, i) => (
<li key={creatureKey(result)}>
<button
type="button"
className={cn(
"flex w-full items-center justify-between px-3 py-1.5 text-left text-sm",
i === suggestionIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg",
)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onSelect(result)}
onMouseEnter={() => onHover(i)}
>
<span>{result.name}</span>
<span className="text-muted-foreground text-xs">
{result.sourceDisplayName}
</span>
</button>
</li>
))}
</ul>
</div>
);
}
interface CustomStatFieldsProps {
customInit: string;
customAc: string;
customMaxHp: string;
onInitChange: (v: string) => void;
onAcChange: (v: string) => void;
onMaxHpChange: (v: string) => void;
}
function CustomStatFields({
customInit,
customAc,
customMaxHp,
onInitChange,
onAcChange,
onMaxHpChange,
}: Readonly<CustomStatFieldsProps>) {
return (
<div className="hidden items-center gap-2 sm:flex">
<Input
type="text"
inputMode="numeric"
value={customInit}
onChange={(e) => onInitChange(e.target.value)}
placeholder="Init"
className="w-16 text-center"
/>
<Input
type="text"
inputMode="numeric"
value={customAc}
onChange={(e) => onAcChange(e.target.value)}
placeholder="AC"
className="w-16 text-center"
/>
<Input
type="text"
inputMode="numeric"
value={customMaxHp}
onChange={(e) => onMaxHpChange(e.target.value)}
placeholder="MaxHP"
className="w-18 text-center"
/>
</div>
);
}
function RollAllButton() {
const { hasCreatureCombatants, canRollAllInitiative } = useEncounterContext();
const { handleRollAllInitiative } = useInitiativeRollsContext();
const [menuPos, setMenuPos] = useState<{
x: number;
y: number;
} | null>(null);
const openMenu = useCallback((x: number, y: number) => {
setMenuPos({ x, y });
}, []);
const longPress = useLongPress(
useCallback(
(e: React.TouchEvent) => {
const touch = e.touches[0];
if (touch) openMenu(touch.clientX, touch.clientY);
},
[openMenu],
),
);
if (!hasCreatureCombatants) return null;
return (
<>
<Button
type="button"
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-hover-action"
onClick={() => handleRollAllInitiative()}
onContextMenu={(e) => {
e.preventDefault();
openMenu(e.clientX, e.clientY);
}}
{...longPress}
disabled={!canRollAllInitiative}
title="Roll all initiative"
aria-label="Roll all initiative"
>
<D20Icon className="h-6 w-6" />
</Button>
{!!menuPos && (
<RollModeMenu
position={menuPos}
onSelect={(mode) => handleRollAllInitiative(mode)}
onClose={() => setMenuPos(null)}
/>
)}
</>
);
}
function buildOverflowItems(opts: {
onManagePlayers?: () => void;
@@ -234,8 +359,9 @@ function buildOverflowItems(opts: {
bestiaryLoaded: boolean;
onBulkImport?: () => void;
bulkImportDisabled?: boolean;
themePreference?: "system" | "light" | "dark";
onCycleTheme?: () => void;
onExportEncounter: () => void;
onImportEncounter: () => void;
onOpenSettings?: () => void;
}): OverflowMenuItem[] {
const items: OverflowMenuItem[] = [];
if (opts.onManagePlayers) {
@@ -260,14 +386,21 @@ function buildOverflowItems(opts: {
disabled: opts.bulkImportDisabled,
});
}
if (opts.onCycleTheme) {
const pref = opts.themePreference ?? "system";
const ThemeIcon = THEME_ICONS[pref];
items.push({
icon: <ThemeIcon className="h-4 w-4" />,
label: THEME_LABELS[pref],
onClick: opts.onCycleTheme,
keepOpen: true,
icon: <Download className="h-4 w-4" />,
label: "Export Encounter",
onClick: opts.onExportEncounter,
});
items.push({
icon: <Upload className="h-4 w-4" />,
label: "Import Encounter",
onClick: opts.onImportEncounter,
});
if (opts.onOpenSettings) {
items.push({
icon: <Settings className="h-4 w-4" />,
label: "Settings",
onClick: opts.onOpenSettings,
});
}
return items;
@@ -277,270 +410,162 @@ export function ActionBar({
inputRef,
autoFocus,
onManagePlayers,
onOpenSettings,
}: Readonly<ActionBarProps>) {
const {
addCombatant,
addFromBestiary,
addFromPlayerCharacter,
hasCreatureCombatants,
canRollAllInitiative,
} = useEncounterContext();
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
useBestiaryContext();
const { characters: playerCharacters } = usePlayerCharactersContext();
const { showBulkImport, showSourceManager, showCreature, panelView } =
useSidePanelContext();
const { preference: themePreference, cycleTheme } = useThemeContext();
const { handleRollAllInitiative } = useInitiativeRollsContext();
nameInput,
suggestions,
pcMatches,
suggestionIndex,
queued,
customInit,
customAc,
customMaxHp,
browseMode,
bestiaryLoaded,
hasSuggestions,
showBulkImport,
showSourceManager,
suggestionActions,
handleNameChange,
handleKeyDown,
handleBrowseKeyDown,
handleAdd,
handleBrowseSelect,
toggleBrowseMode,
setCustomInit,
setCustomAc,
setCustomMaxHp,
} = useActionBarState();
const { state: bulkImportState } = useBulkImportContext();
const {
encounter,
undoRedoState,
isEmpty: encounterIsEmpty,
setEncounter,
setUndoRedoState,
} = useEncounterContext();
const { characters: playerCharacters, replacePlayerCharacters } =
usePlayerCharactersContext();
const handleAddFromBestiary = useCallback(
(result: SearchResult) => {
const creatureId = addFromBestiary(result);
if (creatureId && panelView.mode === "closed") {
showCreature(creatureId);
}
const importFileRef = useRef<HTMLInputElement>(null);
const [importError, setImportError] = useState<string | null>(null);
const [showExportMethod, setShowExportMethod] = useState(false);
const [showImportMethod, setShowImportMethod] = useState(false);
const [showImportConfirm, setShowImportConfirm] = useState(false);
const pendingBundleRef = useRef<
import("@initiative/domain").ExportBundle | null
>(null);
const handleExportDownload = useCallback(
(includeHistory: boolean, filename: string) => {
const bundle = assembleExportBundle(
encounter,
undoRedoState,
playerCharacters,
includeHistory,
);
triggerDownload(bundle, filename);
},
[addFromBestiary, panelView.mode, showCreature],
[encounter, undoRedoState, playerCharacters],
);
const handleViewStatBlock = useCallback(
(result: SearchResult) => {
const slug = result.name
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/(^-|-$)/g, "");
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
showCreature(cId);
const handleExportClipboard = useCallback(
(includeHistory: boolean) => {
const bundle = assembleExportBundle(
encounter,
undoRedoState,
playerCharacters,
includeHistory,
);
void navigator.clipboard.writeText(bundleToJson(bundle));
},
[showCreature],
[encounter, undoRedoState, playerCharacters],
);
const [nameInput, setNameInput] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
const deferredSuggestions = useDeferredValue(suggestions);
const deferredPcMatches = useDeferredValue(pcMatches);
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++) {
handleAddFromBestiary(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: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
if (browseMode) return;
if (queued) {
confirmQueued();
return;
}
if (nameInput.trim() === "") return;
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;
addCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
setNameInput("");
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);
if (browseMode) {
handleBrowseSearch(value);
} else {
handleAddSearch(value);
}
};
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 handleEnter = () => {
if (queued) {
confirmQueued();
} else if (suggestionIndex >= 0) {
handleClickSuggestion(suggestions[suggestionIndex]);
}
};
const hasSuggestions =
deferredSuggestions.length > 0 || deferredPcMatches.length > 0;
const handleKeyDown = (e: React.KeyboardEvent) => {
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));
} 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();
handleViewStatBlock(suggestions[suggestionIndex]);
setBrowseMode(false);
clearInput();
}
};
const handleBrowseSelect = (result: SearchResult) => {
handleViewStatBlock(result);
setBrowseMode(false);
clearInput();
};
const toggleBrowseMode = () => {
setBrowseMode((prev) => {
const next = !prev;
setSuggestionIndex(-1);
setQueued(null);
if (next) {
handleBrowseSearch(nameInput);
} else {
handleAddSearch(nameInput);
}
return next;
const applyImport = useCallback(
(bundle: import("@initiative/domain").ExportBundle) => {
setEncounter(bundle.encounter);
setUndoRedoState({
undoStack: bundle.undoStack,
redoStack: bundle.redoStack,
});
clearCustomFields();
};
const [rollAllMenuPos, setRollAllMenuPos] = useState<{
x: number;
y: number;
} | null>(null);
const openRollAllMenu = useCallback((x: number, y: number) => {
setRollAllMenuPos({ x, y });
}, []);
const rollAllLongPress = useLongPress(
useCallback(
(e: React.TouchEvent) => {
const touch = e.touches[0];
if (touch) openRollAllMenu(touch.clientX, touch.clientY);
replacePlayerCharacters([...bundle.playerCharacters]);
},
[openRollAllMenu],
),
[setEncounter, setUndoRedoState, replacePlayerCharacters],
);
const handleValidatedBundle = useCallback(
(result: import("@initiative/domain").ExportBundle | string) => {
if (typeof result === "string") {
setImportError(result);
return;
}
if (encounterIsEmpty) {
applyImport(result);
} else {
pendingBundleRef.current = result;
setShowImportConfirm(true);
}
},
[encounterIsEmpty, applyImport],
);
const handleImportFile = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (importFileRef.current) importFileRef.current.value = "";
setImportError(null);
handleValidatedBundle(await readImportFile(file));
},
[handleValidatedBundle],
);
const handleImportClipboard = useCallback(
(text: string) => {
setImportError(null);
try {
const parsed: unknown = JSON.parse(text);
handleValidatedBundle(validateImportBundle(parsed));
} catch {
setImportError("Invalid file format");
}
},
[handleValidatedBundle],
);
const handleImportConfirm = useCallback(() => {
if (pendingBundleRef.current) {
applyImport(pendingBundleRef.current);
pendingBundleRef.current = null;
}
setShowImportConfirm(false);
}, [applyImport]);
const handleImportCancel = useCallback(() => {
pendingBundleRef.current = null;
setShowImportConfirm(false);
}, []);
const overflowItems = buildOverflowItems({
onManagePlayers,
onOpenSourceManager: showSourceManager,
bestiaryLoaded,
onBulkImport: showBulkImport,
bulkImportDisabled: bulkImportState.status === "loading",
themePreference,
onCycleTheme: cycleTheme,
onExportEncounter: () => setShowExportMethod(true),
onImportEncounter: () => setShowImportMethod(true),
onOpenSettings,
});
return (
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
<div className="card-glow flex items-center gap-3 border-border border-t bg-card px-4 py-3 sm:rounded-lg sm:border">
<form
onSubmit={handleAdd}
className="relative flex flex-1 items-center gap-2"
className="relative flex flex-1 flex-wrap items-center gap-3 sm:flex-nowrap"
>
<div className="flex-1">
<div className="relative max-w-xs">
@@ -578,112 +603,73 @@ export function ActionBar({
)}
</button>
)}
{browseMode && deferredSuggestions.length > 0 && (
<div className="card-glow absolute bottom-full z-50 mb-1 w-full rounded-lg border border-border bg-card">
<ul className="max-h-48 overflow-y-auto py-1">
{deferredSuggestions.map((result, i) => (
<li key={creatureKey(result)}>
<button
type="button"
className={cn(
"flex w-full items-center justify-between px-3 py-1.5 text-left text-sm",
i === suggestionIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg",
)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleBrowseSelect(result)}
onMouseEnter={() => setSuggestionIndex(i)}
>
<span>{result.name}</span>
<span className="text-muted-foreground text-xs">
{result.sourceDisplayName}
</span>
</button>
</li>
))}
</ul>
</div>
{!!browseMode && (
<BrowseSuggestions
suggestions={suggestions}
suggestionIndex={suggestionIndex}
onSelect={handleBrowseSelect}
onHover={suggestionActions.setSuggestionIndex}
/>
)}
{!browseMode && hasSuggestions && (
<AddModeSuggestions
nameInput={nameInput}
suggestions={deferredSuggestions}
pcMatches={deferredPcMatches}
suggestions={suggestions}
pcMatches={pcMatches}
suggestionIndex={suggestionIndex}
queued={queued}
onDismiss={dismissSuggestions}
onClear={clearInput}
onClickSuggestion={handleClickSuggestion}
onSetSuggestionIndex={setSuggestionIndex}
onSetQueued={setQueued}
onConfirmQueued={confirmQueued}
onAddFromPlayerCharacter={addFromPlayerCharacter}
actions={suggestionActions}
/>
)}
</div>
</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"
<CustomStatFields
customInit={customInit}
customAc={customAc}
customMaxHp={customMaxHp}
onInitChange={setCustomInit}
onAcChange={setCustomAc}
onMaxHpChange={setCustomMaxHp}
/>
<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>
)}
{!!hasCreatureCombatants && (
<>
<Button
type="button"
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-hover-action"
onClick={() => handleRollAllInitiative()}
onContextMenu={(e) => {
e.preventDefault();
openRollAllMenu(e.clientX, e.clientY);
}}
{...rollAllLongPress}
disabled={!canRollAllInitiative}
title="Roll all initiative"
aria-label="Roll all initiative"
>
<D20Icon className="h-6 w-6" />
</Button>
{!!rollAllMenuPos && (
<RollModeMenu
position={rollAllMenuPos}
onSelect={(mode) => handleRollAllInitiative(mode)}
onClose={() => setRollAllMenuPos(null)}
/>
)}
</>
)}
<RollAllButton />
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
</form>
<input
ref={importFileRef}
type="file"
accept=".json"
className="hidden"
onChange={handleImportFile}
/>
{!!importError && (
<Toast
message={importError}
onDismiss={() => setImportError(null)}
autoDismissMs={5000}
/>
)}
<ExportMethodDialog
open={showExportMethod}
onDownload={handleExportDownload}
onCopyToClipboard={handleExportClipboard}
onClose={() => setShowExportMethod(false)}
/>
<ImportMethodDialog
open={showImportMethod}
onSelectFile={() => importFileRef.current?.click()}
onSubmitClipboard={handleImportClipboard}
onClose={() => setShowImportMethod(false)}
/>
<ImportConfirmDialog
open={showImportConfirm}
onConfirm={handleImportConfirm}
onCancel={handleImportCancel}
/>
</div>
);
}
+5 -5
View File
@@ -112,7 +112,7 @@ function EditableName({
onClick={startEditing}
title="Rename"
aria-label="Rename"
className="inline-flex shrink-0 items-center rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-colors transition-opacity hover:bg-hover-neutral-bg hover:text-hover-neutral focus:opacity-100 group-hover:opacity-100"
className="inline-flex pointer-coarse:w-auto w-0 shrink-0 items-center overflow-hidden pointer-coarse:overflow-visible rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-all duration-150 hover:bg-hover-neutral-bg hover:text-hover-neutral focus:w-auto focus:overflow-visible focus:opacity-100 group-hover:w-auto group-hover:overflow-visible group-hover:opacity-100"
>
<Pencil size={14} />
</button>
@@ -157,7 +157,7 @@ function MaxHpDisplay({
inputMode="numeric"
value={draft}
placeholder="Max"
className="h-7 w-[7ch] text-center text-sm tabular-nums"
className="h-7 w-[7ch] text-center tabular-nums"
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
@@ -272,7 +272,7 @@ function AcDisplay({
inputMode="numeric"
value={draft}
placeholder="AC"
className="h-7 w-[6ch] text-center text-sm tabular-nums"
className="h-7 w-[6ch] text-center tabular-nums"
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
@@ -348,7 +348,7 @@ function InitiativeDisplay({
value={draft}
placeholder="--"
className={cn(
"h-7 w-full text-center text-sm tabular-nums",
"h-7 w-full text-center tabular-nums",
dimmed && "opacity-50",
)}
onChange={(e) => setDraft(e.target.value)}
@@ -520,7 +520,7 @@ export function CombatantRow({
isPulsing && "animate-concentration-pulse",
)}
>
<div className="grid grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem] items-center gap-3 py-2">
<div className="grid grid-cols-[2rem_3rem_auto_1fr_auto_2rem] items-center gap-1.5 py-3 sm:grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem] sm:gap-3 sm:py-2">
{/* Concentration */}
<button
type="button"
+23 -63
View File
@@ -1,58 +1,19 @@
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
import type { LucideIcon } from "lucide-react";
import {
ArrowDown,
Ban,
BatteryLow,
Droplet,
EarOff,
EyeOff,
Gem,
Ghost,
Hand,
Heart,
Link,
Moon,
Siren,
Sparkles,
ZapOff,
} from "lucide-react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
type ConditionId,
getConditionDescription,
getConditionsForEdition,
} from "@initiative/domain";
import { useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
import { useClickOutside } from "../hooks/use-click-outside.js";
import { cn } from "../lib/utils";
import {
CONDITION_COLOR_CLASSES,
CONDITION_ICON_MAP,
} from "./condition-styles.js";
import { Tooltip } from "./ui/tooltip.js";
const ICON_MAP: Record<string, LucideIcon> = {
EyeOff,
Heart,
EarOff,
BatteryLow,
Siren,
Hand,
Ban,
Ghost,
ZapOff,
Gem,
Droplet,
ArrowDown,
Link,
Sparkles,
Moon,
};
const COLOR_CLASSES: Record<string, string> = {
neutral: "text-muted-foreground",
pink: "text-pink-400",
amber: "text-amber-400",
orange: "text-orange-400",
gray: "text-gray-400",
violet: "text-violet-400",
yellow: "text-yellow-400",
slate: "text-slate-400",
green: "text-green-400",
indigo: "text-indigo-400",
};
interface ConditionPickerProps {
anchorRef: React.RefObject<HTMLElement | null>;
activeConditions: readonly ConditionId[] | undefined;
@@ -94,16 +55,10 @@ export function ConditionPicker({
setPos({ top, left: anchorRect.left, maxHeight });
}, [anchorRef]);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
useClickOutside(ref, onClose);
const { edition } = useRulesEditionContext();
const conditions = getConditionsForEdition(edition);
const active = new Set(activeConditions ?? []);
return createPortal(
@@ -116,13 +71,18 @@ export function ConditionPicker({
: { visibility: "hidden" as const }
}
>
{CONDITION_DEFINITIONS.map((def) => {
const Icon = ICON_MAP[def.iconName];
{conditions.map((def) => {
const Icon = CONDITION_ICON_MAP[def.iconName];
if (!Icon) return null;
const isActive = active.has(def.id);
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
const colorClass =
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
return (
<Tooltip key={def.id} content={def.description} className="block">
<Tooltip
key={def.id}
content={getConditionDescription(def, edition)}
className="block"
>
<button
type="button"
className={cn(
@@ -0,0 +1,54 @@
import type { LucideIcon } from "lucide-react";
import {
ArrowDown,
Ban,
BatteryLow,
Droplet,
EarOff,
EyeOff,
Gem,
Ghost,
Hand,
Heart,
Link,
Moon,
ShieldMinus,
Siren,
Snail,
Sparkles,
ZapOff,
} from "lucide-react";
export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
EyeOff,
Heart,
EarOff,
BatteryLow,
Siren,
Hand,
Ban,
Ghost,
ZapOff,
Gem,
Droplet,
ArrowDown,
Link,
ShieldMinus,
Snail,
Sparkles,
Moon,
};
export const CONDITION_COLOR_CLASSES: Record<string, string> = {
neutral: "text-muted-foreground",
pink: "text-pink-400",
amber: "text-amber-400",
orange: "text-orange-400",
gray: "text-gray-400",
violet: "text-violet-400",
yellow: "text-yellow-400",
slate: "text-slate-400",
green: "text-green-400",
indigo: "text-indigo-400",
sky: "text-sky-400",
};
+19 -54
View File
@@ -1,57 +1,17 @@
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
import type { LucideIcon } from "lucide-react";
import {
ArrowDown,
Ban,
BatteryLow,
Droplet,
EarOff,
EyeOff,
Gem,
Ghost,
Hand,
Heart,
Link,
Moon,
Plus,
Siren,
Sparkles,
ZapOff,
} from "lucide-react";
CONDITION_DEFINITIONS,
type ConditionId,
getConditionDescription,
} from "@initiative/domain";
import { Plus } from "lucide-react";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
import { cn } from "../lib/utils.js";
import {
CONDITION_COLOR_CLASSES,
CONDITION_ICON_MAP,
} from "./condition-styles.js";
import { Tooltip } from "./ui/tooltip.js";
const ICON_MAP: Record<string, LucideIcon> = {
EyeOff,
Heart,
EarOff,
BatteryLow,
Siren,
Hand,
Ban,
Ghost,
ZapOff,
Gem,
Droplet,
ArrowDown,
Link,
Sparkles,
Moon,
};
const COLOR_CLASSES: Record<string, string> = {
neutral: "text-muted-foreground",
pink: "text-pink-400",
amber: "text-amber-400",
orange: "text-orange-400",
gray: "text-gray-400",
violet: "text-violet-400",
yellow: "text-yellow-400",
slate: "text-slate-400",
green: "text-green-400",
indigo: "text-indigo-400",
};
interface ConditionTagsProps {
conditions: readonly ConditionId[] | undefined;
onRemove: (conditionId: ConditionId) => void;
@@ -63,16 +23,21 @@ export function ConditionTags({
onRemove,
onOpenPicker,
}: Readonly<ConditionTagsProps>) {
const { edition } = useRulesEditionContext();
return (
<div className="flex flex-wrap items-center gap-0.5">
{conditions?.map((condId) => {
const def = CONDITION_DEFINITIONS.find((d) => d.id === condId);
if (!def) return null;
const Icon = ICON_MAP[def.iconName];
const Icon = CONDITION_ICON_MAP[def.iconName];
if (!Icon) return null;
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
const colorClass =
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
return (
<Tooltip key={condId} content={`${def.label}: ${def.description}`}>
<Tooltip
key={condId}
content={`${def.label}:\n${getConditionDescription(def, edition)}`}
>
<button
type="button"
aria-label={`Remove ${def.label}`}
@@ -94,7 +59,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 opacity-0 pointer-coarse:opacity-100 transition-colors transition-opacity hover:bg-hover-neutral-bg hover:text-hover-neutral focus:opacity-100 group-hover:opacity-100"
className="inline-flex pointer-coarse:w-auto w-0 items-center overflow-hidden pointer-coarse:overflow-visible rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-all duration-150 hover:bg-hover-neutral-bg hover:text-hover-neutral focus:w-auto focus:overflow-visible focus:opacity-100 group-hover:w-auto group-hover:overflow-visible group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onOpenPicker();
+46 -36
View File
@@ -1,11 +1,19 @@
import type { PlayerCharacter } from "@initiative/domain";
import { X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { ColorPalette } from "./color-palette";
import { IconGrid } from "./icon-grid";
import { Button } from "./ui/button";
import { Dialog } from "./ui/dialog";
import { Input } from "./ui/input";
function parseLevel(value: string): number | undefined | "invalid" {
if (value.trim() === "") return undefined;
const n = Number.parseInt(value, 10);
if (Number.isNaN(n) || n < 1 || n > 20) return "invalid";
return n;
}
interface CreatePlayerModalProps {
open: boolean;
onClose: () => void;
@@ -15,6 +23,7 @@ interface CreatePlayerModalProps {
maxHp: number,
color: string | undefined,
icon: string | undefined,
level: number | undefined,
) => void;
playerCharacter?: PlayerCharacter;
}
@@ -25,12 +34,12 @@ export function CreatePlayerModal({
onSave,
playerCharacter,
}: Readonly<CreatePlayerModalProps>) {
const dialogRef = useRef<HTMLDialogElement>(null);
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 [level, setLevel] = useState("");
const [error, setError] = useState("");
const isEdit = !!playerCharacter;
@@ -43,45 +52,23 @@ export function CreatePlayerModal({
setMaxHp(String(playerCharacter.maxHp));
setColor(playerCharacter.color ?? "");
setIcon(playerCharacter.icon ?? "");
setLevel(
playerCharacter.level === undefined
? ""
: String(playerCharacter.level),
);
} else {
setName("");
setAc("10");
setMaxHp("10");
setColor("");
setIcon("");
setLevel("");
}
setError("");
}
}, [open, playerCharacter]);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) {
dialog.showModal();
} else if (!open && dialog.open) {
dialog.close();
}
}, [open]);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
function handleCancel(e: Event) {
e.preventDefault();
onClose();
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === dialog) onClose();
}
dialog.addEventListener("cancel", handleCancel);
dialog.addEventListener("mousedown", handleBackdropClick);
return () => {
dialog.removeEventListener("cancel", handleCancel);
dialog.removeEventListener("mousedown", handleBackdropClick);
};
}, [onClose]);
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
const trimmed = name.trim();
@@ -99,15 +86,24 @@ export function CreatePlayerModal({
setError("Max HP must be at least 1");
return;
}
onSave(trimmed, acNum, hpNum, color || undefined, icon || undefined);
const levelNum = parseLevel(level);
if (levelNum === "invalid") {
setError("Level must be between 1 and 20");
return;
}
onSave(
trimmed,
acNum,
hpNum,
color || undefined,
icon || undefined,
levelNum,
);
onClose();
};
return (
<dialog
ref={dialogRef}
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
>
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold text-foreground text-lg">
{isEdit ? "Edit Player" : "Create Player"}
@@ -166,6 +162,20 @@ export function CreatePlayerModal({
className="text-center"
/>
</div>
<div className="flex-1">
<span className="mb-1 block text-muted-foreground text-sm">
Level
</span>
<Input
type="text"
inputMode="numeric"
value={level}
onChange={(e) => setLevel(e.target.value)}
placeholder="1-20"
aria-label="Level"
className="text-center"
/>
</div>
</div>
<div>
@@ -187,6 +197,6 @@ export function CreatePlayerModal({
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
</div>
</form>
</dialog>
</Dialog>
);
}
@@ -0,0 +1,39 @@
import type { DifficultyResult, DifficultyTier } from "@initiative/domain";
import { cn } from "../lib/utils.js";
const TIER_CONFIG: Record<
DifficultyTier,
{ filledBars: number; color: string; label: string }
> = {
trivial: { filledBars: 0, color: "", label: "Trivial" },
low: { filledBars: 1, color: "bg-green-500", label: "Low" },
moderate: { filledBars: 2, color: "bg-yellow-500", label: "Moderate" },
high: { filledBars: 3, color: "bg-red-500", label: "High" },
};
const BAR_HEIGHTS = ["h-2", "h-3", "h-4"] as const;
export function DifficultyIndicator({ result }: { result: DifficultyResult }) {
const config = TIER_CONFIG[result.tier];
const tooltip = `${config.label} encounter difficulty`;
return (
<div
className="flex items-end gap-0.5"
title={tooltip}
role="img"
aria-label={tooltip}
>
{BAR_HEIGHTS.map((height, i) => (
<div
key={height}
className={cn(
"w-1 rounded-sm",
height,
i < config.filledBars ? config.color : "bg-muted",
)}
/>
))}
</div>
);
}
@@ -0,0 +1,93 @@
import { Check, ClipboardCopy, Download } from "lucide-react";
import { useCallback, useState } from "react";
import { Dialog, DialogHeader } from "./ui/dialog.js";
import { Input } from "./ui/input.js";
interface ExportMethodDialogProps {
open: boolean;
onDownload: (includeHistory: boolean, filename: string) => void;
onCopyToClipboard: (includeHistory: boolean) => void;
onClose: () => void;
}
export function ExportMethodDialog({
open,
onDownload,
onCopyToClipboard,
onClose,
}: Readonly<ExportMethodDialogProps>) {
const [includeHistory, setIncludeHistory] = useState(false);
const [filename, setFilename] = useState("");
const [copied, setCopied] = useState(false);
const handleClose = useCallback(() => {
setIncludeHistory(false);
setFilename("");
setCopied(false);
onClose();
}, [onClose]);
return (
<Dialog open={open} onClose={handleClose} className="w-80">
<DialogHeader title="Export Encounter" onClose={handleClose} />
<div className="mb-3">
<Input
type="text"
value={filename}
onChange={(e) => setFilename(e.target.value)}
placeholder="Filename (optional)"
/>
</div>
<label className="mb-4 flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={includeHistory}
onChange={(e) => setIncludeHistory(e.target.checked)}
className="accent-accent"
/>
<span className="text-foreground">Include undo/redo history</span>
</label>
<div className="flex flex-col gap-2">
<button
type="button"
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
onClick={() => {
onDownload(includeHistory, filename);
handleClose();
}}
>
<Download className="h-5 w-5 text-muted-foreground" />
<div>
<div className="font-medium">Download file</div>
<div className="text-muted-foreground text-xs">
Save as a JSON file
</div>
</div>
</button>
<button
type="button"
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
onClick={() => {
onCopyToClipboard(includeHistory);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
>
{copied ? (
<Check className="h-5 w-5 text-green-400" />
) : (
<ClipboardCopy className="h-5 w-5 text-muted-foreground" />
)}
<div>
<div className="font-medium">
{copied ? "Copied!" : "Copy to clipboard"}
</div>
<div className="text-muted-foreground text-xs">
Copy JSON to your clipboard
</div>
</div>
</button>
</div>
</Dialog>
);
}
+3 -10
View File
@@ -6,6 +6,7 @@ import {
useRef,
useState,
} from "react";
import { useClickOutside } from "../hooks/use-click-outside.js";
import { Input } from "./ui/input";
const DIGITS_ONLY_REGEX = /^\d+$/;
@@ -48,15 +49,7 @@ export function HpAdjustPopover({
requestAnimationFrame(() => inputRef.current?.focus());
}, []);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
useClickOutside(ref, onClose);
const parsedValue =
inputValue === "" ? null : Number.parseInt(inputValue, 10);
@@ -106,7 +99,7 @@ export function HpAdjustPopover({
inputMode="numeric"
value={inputValue}
placeholder="HP"
className="h-7 w-[7ch] text-center text-sm tabular-nums"
className="h-7 w-[7ch] text-center tabular-nums"
onChange={(e) => {
const v = e.target.value;
if (v === "" || DIGITS_ONLY_REGEX.test(v)) {
@@ -0,0 +1,32 @@
import { Button } from "./ui/button.js";
import { Dialog } from "./ui/dialog.js";
interface ImportConfirmDialogProps {
open: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export function ImportConfirmDialog({
open,
onConfirm,
onCancel,
}: Readonly<ImportConfirmDialogProps>) {
return (
<Dialog open={open} onClose={onCancel}>
<h2 className="mb-2 font-semibold text-lg">Replace current encounter?</h2>
<p className="mb-4 text-muted-foreground text-sm">
Importing will replace your current encounter, undo/redo history, and
player characters. This cannot be undone.
</p>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button type="button" onClick={onConfirm}>
Import
</Button>
</div>
</Dialog>
);
}
@@ -0,0 +1,114 @@
import { ClipboardPaste, FileUp } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "./ui/button.js";
import { Dialog, DialogHeader } from "./ui/dialog.js";
interface ImportMethodDialogProps {
open: boolean;
onSelectFile: () => void;
onSubmitClipboard: (text: string) => void;
onClose: () => void;
}
export function ImportMethodDialog({
open,
onSelectFile,
onSubmitClipboard,
onClose,
}: Readonly<ImportMethodDialogProps>) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [mode, setMode] = useState<"pick" | "paste">("pick");
const [pasteText, setPasteText] = useState("");
const handleClose = useCallback(() => {
setMode("pick");
setPasteText("");
onClose();
}, [onClose]);
useEffect(() => {
if (!open) {
setMode("pick");
setPasteText("");
}
}, [open]);
useEffect(() => {
if (mode === "paste") {
textareaRef.current?.focus();
}
}, [mode]);
return (
<Dialog open={open} onClose={handleClose} className="w-80">
<DialogHeader title="Import Encounter" onClose={handleClose} />
{mode === "pick" && (
<div className="flex flex-col gap-2">
<button
type="button"
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
onClick={() => {
handleClose();
onSelectFile();
}}
>
<FileUp className="h-5 w-5 text-muted-foreground" />
<div>
<div className="font-medium">From file</div>
<div className="text-muted-foreground text-xs">
Upload a JSON file
</div>
</div>
</button>
<button
type="button"
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
onClick={() => setMode("paste")}
>
<ClipboardPaste className="h-5 w-5 text-muted-foreground" />
<div>
<div className="font-medium">Paste content</div>
<div className="text-muted-foreground text-xs">
Paste JSON content directly
</div>
</div>
</button>
</div>
)}
{mode === "paste" && (
<div className="flex flex-col gap-3">
<textarea
ref={textareaRef}
value={pasteText}
onChange={(e) => setPasteText(e.target.value)}
placeholder="Paste exported JSON here..."
className="h-32 w-full resize-none rounded-md border border-border bg-background px-3 py-2 font-mono text-foreground text-xs placeholder:text-muted-foreground focus:border-accent focus:outline-none"
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
onClick={() => {
setMode("pick");
setPasteText("");
}}
>
Back
</Button>
<Button
type="button"
disabled={pasteText.trim().length === 0}
onClick={() => {
const text = pasteText;
handleClose();
onSubmitClipboard(text);
}}
>
Import
</Button>
</div>
</div>
)}
</Dialog>
);
}
@@ -35,7 +35,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
setEditingPlayer(undefined);
setManagementOpen(true);
}}
onSave={(name, ac, maxHp, color, icon) => {
onSave={(name, ac, maxHp, color, icon, level) => {
if (editingPlayer) {
editCharacter(editingPlayer.id, {
name,
@@ -43,9 +43,10 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
maxHp,
color: color ?? null,
icon: icon ?? null,
level: level ?? null,
});
} else {
createCharacter(name, ac, maxHp, color, icon);
createCharacter(name, ac, maxHp, color, icon, level);
}
}}
playerCharacter={editingPlayer}
+10 -50
View File
@@ -1,9 +1,9 @@
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
import { Pencil, Plus, Trash2, X } from "lucide-react";
import { useEffect, useRef } from "react";
import { Pencil, Plus, Trash2 } from "lucide-react";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
import { Button } from "./ui/button";
import { ConfirmButton } from "./ui/confirm-button";
import { Dialog, DialogHeader } from "./ui/dialog";
interface PlayerManagementProps {
open: boolean;
@@ -22,54 +22,9 @@ export function PlayerManagement({
onDelete,
onCreate,
}: Readonly<PlayerManagementProps>) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) {
dialog.showModal();
} else if (!open && dialog.open) {
dialog.close();
}
}, [open]);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
function handleCancel(e: Event) {
e.preventDefault();
onClose();
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === dialog) onClose();
}
dialog.addEventListener("cancel", handleCancel);
dialog.addEventListener("mousedown", handleBackdropClick);
return () => {
dialog.removeEventListener("cancel", handleCancel);
dialog.removeEventListener("mousedown", handleBackdropClick);
};
}, [onClose]);
return (
<dialog
ref={dialogRef}
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
>
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold text-foreground text-lg">
Player Characters
</h2>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="text-muted-foreground"
>
<X size={20} />
</Button>
</div>
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
<DialogHeader title="Player Characters" onClose={onClose} />
{characters.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-8 text-center">
@@ -101,6 +56,11 @@ export function PlayerManagement({
<span className="text-muted-foreground text-xs tabular-nums">
HP {pc.maxHp}
</span>
{pc.level !== undefined && (
<span className="text-muted-foreground text-xs tabular-nums">
Lv {pc.level}
</span>
)}
<Button
variant="ghost"
size="icon-sm"
@@ -128,6 +88,6 @@ export function PlayerManagement({
</div>
</div>
)}
</dialog>
</Dialog>
);
}
+3 -17
View File
@@ -1,6 +1,7 @@
import type { RollMode } from "@initiative/domain";
import { ChevronsDown, ChevronsUp } from "lucide-react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useLayoutEffect, useRef, useState } from "react";
import { useClickOutside } from "../hooks/use-click-outside.js";
interface RollModeMenuProps {
readonly position: { x: number; y: number };
@@ -34,22 +35,7 @@ export function RollModeMenu({
setPos({ top, left });
}, [position.x, position.y]);
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [onClose]);
useClickOutside(ref, onClose);
return (
<div
@@ -0,0 +1,89 @@
import type { RulesEdition } from "@initiative/domain";
import { Monitor, Moon, Sun } from "lucide-react";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
import { useThemeContext } from "../contexts/theme-context.js";
import { cn } from "../lib/utils.js";
import { Dialog, DialogHeader } from "./ui/dialog.js";
interface SettingsModalProps {
open: boolean;
onClose: () => void;
}
const EDITION_OPTIONS: { value: RulesEdition; label: string }[] = [
{ value: "5e", label: "5e (2014)" },
{ value: "5.5e", label: "5.5e (2024)" },
];
const THEME_OPTIONS: {
value: "system" | "light" | "dark";
label: string;
icon: typeof Sun;
}[] = [
{ value: "system", label: "System", icon: Monitor },
{ value: "light", label: "Light", icon: Sun },
{ value: "dark", label: "Dark", icon: Moon },
];
export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
const { edition, setEdition } = useRulesEditionContext();
const { preference, setPreference } = useThemeContext();
return (
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-sm">
<DialogHeader title="Settings" onClose={onClose} />
<div className="flex flex-col gap-5">
<div>
<span className="mb-2 block font-medium text-muted-foreground text-sm">
Conditions
</span>
<div className="flex gap-1">
{EDITION_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={cn(
"flex-1 rounded-md px-3 py-1.5 text-sm transition-colors",
edition === opt.value
? "bg-accent text-primary-foreground"
: "bg-card text-muted-foreground hover:bg-hover-neutral-bg hover:text-foreground",
)}
onClick={() => setEdition(opt.value)}
>
{opt.label}
</button>
))}
</div>
</div>
<div>
<span className="mb-2 block font-medium text-muted-foreground text-sm">
Theme
</span>
<div className="flex gap-1">
{THEME_OPTIONS.map((opt) => {
const Icon = opt.icon;
return (
<button
key={opt.value}
type="button"
className={cn(
"flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm transition-colors",
preference === opt.value
? "bg-accent text-primary-foreground"
: "bg-card text-muted-foreground hover:bg-hover-neutral-bg hover:text-foreground",
)}
onClick={() => setPreference(opt.value)}
>
<Icon size={14} />
{opt.label}
</button>
);
})}
</div>
</div>
</div>
</Dialog>
);
}
+29 -59
View File
@@ -34,6 +34,31 @@ function SectionDivider() {
);
}
function TraitSection({
entries,
heading,
}: Readonly<{
entries: readonly { name: string; text: string }[] | undefined;
heading?: string;
}>) {
if (!entries || entries.length === 0) return null;
return (
<>
<SectionDivider />
{heading ? (
<h3 className="font-bold text-base text-stat-heading">{heading}</h3>
) : null}
<div className="space-y-2">
{entries.map((e) => (
<div key={e.name} className="text-sm">
<span className="font-semibold italic">{e.name}.</span> {e.text}
</div>
))}
</div>
</>
);
}
export function StatBlock({ creature }: Readonly<StatBlockProps>) {
const abilities = [
{ label: "STR", score: creature.abilities.str },
@@ -134,19 +159,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
</div>
</div>
{/* Traits */}
{creature.traits && creature.traits.length > 0 && (
<>
<SectionDivider />
<div className="space-y-2">
{creature.traits.map((t) => (
<div key={t.name} className="text-sm">
<span className="font-semibold italic">{t.name}.</span> {t.text}
</div>
))}
</div>
</>
)}
<TraitSection entries={creature.traits} />
{/* Spellcasting */}
{creature.spellcasting && creature.spellcasting.length > 0 && (
@@ -190,52 +203,9 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
</>
)}
{/* Actions */}
{creature.actions && creature.actions.length > 0 && (
<>
<SectionDivider />
<h3 className="font-bold text-base text-stat-heading">Actions</h3>
<div className="space-y-2">
{creature.actions.map((a) => (
<div key={a.name} className="text-sm">
<span className="font-semibold italic">{a.name}.</span> {a.text}
</div>
))}
</div>
</>
)}
{/* Bonus Actions */}
{creature.bonusActions && creature.bonusActions.length > 0 && (
<>
<SectionDivider />
<h3 className="font-bold text-base text-stat-heading">
Bonus Actions
</h3>
<div className="space-y-2">
{creature.bonusActions.map((a) => (
<div key={a.name} className="text-sm">
<span className="font-semibold italic">{a.name}.</span> {a.text}
</div>
))}
</div>
</>
)}
{/* Reactions */}
{creature.reactions && creature.reactions.length > 0 && (
<>
<SectionDivider />
<h3 className="font-bold text-base text-stat-heading">Reactions</h3>
<div className="space-y-2">
{creature.reactions.map((a) => (
<div key={a.name} className="text-sm">
<span className="font-semibold italic">{a.name}.</span> {a.text}
</div>
))}
</div>
</>
)}
<TraitSection entries={creature.actions} heading="Actions" />
<TraitSection entries={creature.bonusActions} heading="Bonus Actions" />
<TraitSection entries={creature.reactions} heading="Reactions" />
{/* Legendary Actions */}
{!!creature.legendaryActions && (
+39 -4
View File
@@ -1,18 +1,29 @@
import { StepBack, StepForward, Trash2 } from "lucide-react";
import { Redo2, StepBack, StepForward, Trash2, Undo2 } from "lucide-react";
import { useEncounterContext } from "../contexts/encounter-context.js";
import { useDifficulty } from "../hooks/use-difficulty.js";
import { DifficultyIndicator } from "./difficulty-indicator.js";
import { Button } from "./ui/button.js";
import { ConfirmButton } from "./ui/confirm-button.js";
export function TurnNavigation() {
const { encounter, advanceTurn, retreatTurn, clearEncounter } =
useEncounterContext();
const {
encounter,
advanceTurn,
retreatTurn,
clearEncounter,
undo,
redo,
canUndo,
canRedo,
} = useEncounterContext();
const difficulty = useDifficulty();
const hasCombatants = encounter.combatants.length > 0;
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
const activeCombatant = encounter.combatants[encounter.activeIndex];
return (
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
<div className="card-glow flex items-center gap-3 border-border border-b bg-card px-4 py-3 sm:rounded-lg sm:border">
<Button
variant="ghost"
size="icon"
@@ -24,6 +35,29 @@ export function TurnNavigation() {
<StepBack className="h-5 w-5" />
</Button>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={undo}
disabled={!canUndo}
title="Undo"
aria-label="Undo"
>
<Undo2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={redo}
disabled={!canRedo}
title="Redo"
aria-label="Redo"
>
<Redo2 className="h-4 w-4" />
</Button>
</div>
<div className="flex min-w-0 flex-1 items-center justify-center gap-2 text-sm">
<span className="shrink-0 rounded-md bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
<span className="-mt-[3px] inline-block">
@@ -35,6 +69,7 @@ export function TurnNavigation() {
) : (
<span className="text-muted-foreground">No combatants</span>
)}
{difficulty && <DifficultyIndicator result={difficulty} />}
</div>
<div className="flex flex-shrink-0 items-center gap-3">
+2 -26
View File
@@ -6,6 +6,7 @@ import {
useRef,
useState,
} from "react";
import { useClickOutside } from "../../hooks/use-click-outside.js";
import { cn } from "../../lib/utils";
import { Button } from "./button";
@@ -42,32 +43,7 @@ export function ConfirmButton({
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 handleEscapeKey(e: KeyboardEvent) {
if (e.key === "Escape") {
revert();
}
}
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleEscapeKey);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleEscapeKey);
};
}, [isConfirming, revert]);
useClickOutside(wrapperRef, revert, isConfirming);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
+71
View File
@@ -0,0 +1,71 @@
import { X } from "lucide-react";
import { type ReactNode, useEffect, useRef } from "react";
import { cn } from "../../lib/utils.js";
import { Button } from "./button.js";
interface DialogProps {
open: boolean;
onClose: () => void;
className?: string;
children: ReactNode;
}
export function Dialog({ open, onClose, className, children }: DialogProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
else if (!open && dialog.open) dialog.close();
}, [open]);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
function handleCancel(e: Event) {
e.preventDefault();
onClose();
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === dialog) onClose();
}
dialog.addEventListener("cancel", handleCancel);
dialog.addEventListener("mousedown", handleBackdropClick);
return () => {
dialog.removeEventListener("cancel", handleCancel);
dialog.removeEventListener("mousedown", handleBackdropClick);
};
}, [onClose]);
return (
<dialog
ref={dialogRef}
className={cn(
"m-auto rounded-lg border border-border bg-card text-foreground shadow-xl backdrop:bg-black/50",
className,
)}
>
<div className="p-6">{children}</div>
</dialog>
);
}
export function DialogHeader({
title,
onClose,
}: Readonly<{ title: string; onClose: () => void }>) {
return (
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold text-foreground text-lg">{title}</h2>
<Button
variant="ghost"
size="icon-sm"
onClick={onClose}
className="text-muted-foreground"
>
<X className="h-4 w-4" />
</Button>
</div>
);
}
+1 -1
View File
@@ -12,7 +12,7 @@ export const Input = ({
<input
ref={ref}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-foreground text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50",
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50 sm:text-sm",
className,
)}
{...props}
+3 -18
View File
@@ -1,5 +1,6 @@
import { EllipsisVertical } from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { type ReactNode, useRef, useState } from "react";
import { useClickOutside } from "../../hooks/use-click-outside.js";
import { Button } from "./button";
export interface OverflowMenuItem {
@@ -18,23 +19,7 @@ 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]);
useClickOutside(ref, () => setOpen(false), open);
return (
<div ref={ref} className="relative">
+1 -1
View File
@@ -43,7 +43,7 @@ export function Tooltip({
createPortal(
<div
role="tooltip"
className="pointer-events-none fixed z-[60] max-w-64 -translate-x-1/2 -translate-y-full rounded-md border border-border bg-background px-2.5 py-1.5 text-foreground text-xs leading-snug shadow-lg"
className="pointer-events-none fixed z-[60] max-w-64 -translate-x-1/2 -translate-y-full whitespace-pre-line rounded-md border border-border bg-background px-2.5 py-1.5 text-foreground text-xs leading-snug shadow-lg"
style={{ top: pos.top, left: pos.left }}
>
{content}
@@ -1,5 +1,6 @@
import { createContext, type ReactNode, useContext } from "react";
import { useEncounter } from "../hooks/use-encounter.js";
import { useUndoRedoShortcuts } from "../hooks/use-undo-redo-shortcuts.js";
type EncounterContextValue = ReturnType<typeof useEncounter>;
@@ -7,6 +8,7 @@ const EncounterContext = createContext<EncounterContextValue | null>(null);
export function EncounterProvider({ children }: { children: ReactNode }) {
const value = useEncounter();
useUndoRedoShortcuts(value.undo, value.redo, value.canUndo, value.canRedo);
return (
<EncounterContext.Provider value={value}>
{children}
+1
View File
@@ -3,5 +3,6 @@ export { BulkImportProvider } from "./bulk-import-context.js";
export { EncounterProvider } from "./encounter-context.js";
export { InitiativeRollsProvider } from "./initiative-rolls-context.js";
export { PlayerCharactersProvider } from "./player-characters-context.js";
export { RulesEditionProvider } from "./rules-edition-context.js";
export { SidePanelProvider } from "./side-panel-context.js";
export { ThemeProvider } from "./theme-context.js";
@@ -0,0 +1,24 @@
import { createContext, type ReactNode, useContext } from "react";
import { useRulesEdition } from "../hooks/use-rules-edition.js";
type RulesEditionContextValue = ReturnType<typeof useRulesEdition>;
const RulesEditionContext = createContext<RulesEditionContextValue | null>(
null,
);
export function RulesEditionProvider({ children }: { children: ReactNode }) {
const value = useRulesEdition();
return (
<RulesEditionContext.Provider value={value}>
{children}
</RulesEditionContext.Provider>
);
}
export function useRulesEditionContext(): RulesEditionContextValue {
const ctx = useContext(RulesEditionContext);
if (!ctx)
throw new Error("useRulesEditionContext requires RulesEditionProvider");
return ctx;
}
@@ -0,0 +1,416 @@
import type {
BestiaryIndexEntry,
ConditionId,
PlayerCharacter,
} from "@initiative/domain";
import {
combatantId,
createEncounter,
EMPTY_UNDO_REDO_STATE,
isDomainError,
playerCharacterId,
} from "@initiative/domain";
import { describe, expect, it, vi } from "vitest";
import { type EncounterState, encounterReducer } from "../use-encounter.js";
vi.mock("../../persistence/encounter-storage.js", () => ({
loadEncounter: vi.fn().mockReturnValue(null),
saveEncounter: vi.fn(),
}));
vi.mock("../../persistence/undo-redo-storage.js", () => ({
loadUndoRedoStacks: vi.fn().mockReturnValue(EMPTY_UNDO_REDO_STATE),
saveUndoRedoStacks: vi.fn(),
}));
function emptyState(): EncounterState {
return {
encounter: {
combatants: [],
activeIndex: 0,
roundNumber: 1,
},
undoRedoState: EMPTY_UNDO_REDO_STATE,
events: [],
nextId: 0,
lastCreatureId: null,
};
}
function stateWith(...names: string[]): EncounterState {
let state = emptyState();
for (const name of names) {
state = encounterReducer(state, { type: "add-combatant", name });
}
return state;
}
function stateWithHp(name: string, maxHp: number): EncounterState {
const state = stateWith(name);
const id = state.encounter.combatants[0].id;
return encounterReducer(state, {
type: "set-hp",
id,
maxHp,
});
}
const BESTIARY_ENTRY: BestiaryIndexEntry = {
name: "Goblin",
source: "MM",
ac: 15,
hp: 7,
dex: 14,
cr: "1/4",
initiativeProficiency: 0,
size: "Small",
type: "humanoid",
};
describe("encounterReducer", () => {
describe("add-combatant", () => {
it("adds a combatant and pushes undo", () => {
const next = encounterReducer(emptyState(), {
type: "add-combatant",
name: "Goblin",
});
expect(next.encounter.combatants).toHaveLength(1);
expect(next.encounter.combatants[0].name).toBe("Goblin");
expect(next.undoRedoState.undoStack).toHaveLength(1);
expect(next.nextId).toBe(1);
});
it("applies optional init values", () => {
const next = encounterReducer(emptyState(), {
type: "add-combatant",
name: "Goblin",
init: { initiative: 15, ac: 13, maxHp: 7 },
});
const c = next.encounter.combatants[0];
expect(c.initiative).toBe(15);
expect(c.ac).toBe(13);
expect(c.maxHp).toBe(7);
expect(c.currentHp).toBe(7);
});
it("increments IDs", () => {
const s1 = encounterReducer(emptyState(), {
type: "add-combatant",
name: "A",
});
const s2 = encounterReducer(s1, {
type: "add-combatant",
name: "B",
});
expect(s2.encounter.combatants[0].id).toBe("c-1");
expect(s2.encounter.combatants[1].id).toBe("c-2");
});
it("returns unchanged state for invalid name", () => {
const state = emptyState();
const next = encounterReducer(state, {
type: "add-combatant",
name: "",
});
expect(next).toBe(state);
});
});
describe("remove-combatant", () => {
it("removes combatant and pushes undo", () => {
const state = stateWith("Goblin");
const id = state.encounter.combatants[0].id;
const next = encounterReducer(state, {
type: "remove-combatant",
id,
});
expect(next.encounter.combatants).toHaveLength(0);
expect(next.undoRedoState.undoStack).toHaveLength(2);
});
});
describe("edit-combatant", () => {
it("renames combatant", () => {
const state = stateWith("Goblin");
const id = state.encounter.combatants[0].id;
const next = encounterReducer(state, {
type: "edit-combatant",
id,
newName: "Hobgoblin",
});
expect(next.encounter.combatants[0].name).toBe("Hobgoblin");
});
});
describe("advance-turn / retreat-turn", () => {
it("advances and retreats turn", () => {
const state = stateWith("A", "B");
const advanced = encounterReducer(state, {
type: "advance-turn",
});
expect(advanced.encounter.activeIndex).toBe(1);
const retreated = encounterReducer(advanced, {
type: "retreat-turn",
});
expect(retreated.encounter.activeIndex).toBe(0);
});
it("returns unchanged state on empty encounter", () => {
const state = emptyState();
const next = encounterReducer(state, { type: "advance-turn" });
expect(next).toBe(state);
});
});
describe("set-hp / adjust-hp / set-temp-hp", () => {
it("sets max HP", () => {
const state = stateWith("Goblin");
const id = state.encounter.combatants[0].id;
const next = encounterReducer(state, {
type: "set-hp",
id,
maxHp: 20,
});
expect(next.encounter.combatants[0].maxHp).toBe(20);
expect(next.encounter.combatants[0].currentHp).toBe(20);
});
it("adjusts HP", () => {
const state = stateWithHp("Goblin", 20);
const id = state.encounter.combatants[0].id;
const next = encounterReducer(state, {
type: "adjust-hp",
id,
delta: -5,
});
expect(next.encounter.combatants[0].currentHp).toBe(15);
});
it("sets temp HP", () => {
const state = stateWithHp("Goblin", 20);
const id = state.encounter.combatants[0].id;
const next = encounterReducer(state, {
type: "set-temp-hp",
id,
tempHp: 5,
});
expect(next.encounter.combatants[0].tempHp).toBe(5);
});
});
describe("set-ac", () => {
it("sets AC", () => {
const state = stateWith("Goblin");
const id = state.encounter.combatants[0].id;
const next = encounterReducer(state, {
type: "set-ac",
id,
value: 15,
});
expect(next.encounter.combatants[0].ac).toBe(15);
});
});
describe("set-initiative", () => {
it("sets initiative", () => {
const state = stateWith("Goblin");
const id = state.encounter.combatants[0].id;
const next = encounterReducer(state, {
type: "set-initiative",
id,
value: 18,
});
expect(next.encounter.combatants[0].initiative).toBe(18);
});
});
describe("toggle-condition / toggle-concentration", () => {
it("toggles condition", () => {
const state = stateWith("Goblin");
const id = state.encounter.combatants[0].id;
const next = encounterReducer(state, {
type: "toggle-condition",
id,
conditionId: "blinded" as ConditionId,
});
expect(next.encounter.combatants[0].conditions).toContain("blinded");
});
it("toggles concentration", () => {
const state = stateWith("Wizard");
const id = state.encounter.combatants[0].id;
const next = encounterReducer(state, {
type: "toggle-concentration",
id,
});
expect(next.encounter.combatants[0].isConcentrating).toBe(true);
});
});
describe("clear-encounter", () => {
it("clears combatants, resets history and nextId", () => {
const state = stateWith("A", "B");
const next = encounterReducer(state, {
type: "clear-encounter",
});
expect(next.encounter.combatants).toHaveLength(0);
expect(next.undoRedoState.undoStack).toHaveLength(0);
expect(next.undoRedoState.redoStack).toHaveLength(0);
expect(next.nextId).toBe(0);
});
});
describe("undo / redo", () => {
it("undo restores previous state", () => {
const state = stateWith("Goblin");
const next = encounterReducer(state, { type: "undo" });
expect(next.encounter.combatants).toHaveLength(0);
expect(next.undoRedoState.undoStack).toHaveLength(0);
expect(next.undoRedoState.redoStack).toHaveLength(1);
});
it("redo restores undone state", () => {
const state = stateWith("Goblin");
const undone = encounterReducer(state, { type: "undo" });
const redone = encounterReducer(undone, { type: "redo" });
expect(redone.encounter.combatants).toHaveLength(1);
expect(redone.encounter.combatants[0].name).toBe("Goblin");
});
it("undo returns unchanged state when stack is empty", () => {
const state = emptyState();
const next = encounterReducer(state, { type: "undo" });
expect(next).toBe(state);
});
it("redo returns unchanged state when stack is empty", () => {
const state = emptyState();
const next = encounterReducer(state, { type: "redo" });
expect(next).toBe(state);
});
});
describe("add-from-bestiary", () => {
it("adds creature with HP, AC, and creatureId", () => {
const next = encounterReducer(emptyState(), {
type: "add-from-bestiary",
entry: BESTIARY_ENTRY,
});
const c = next.encounter.combatants[0];
expect(c.name).toBe("Goblin");
expect(c.maxHp).toBe(7);
expect(c.ac).toBe(15);
expect(c.creatureId).toBe("mm:goblin");
expect(next.lastCreatureId).toBe("mm:goblin");
expect(next.undoRedoState.undoStack).toHaveLength(1);
});
it("auto-numbers duplicate names", () => {
const s1 = encounterReducer(emptyState(), {
type: "add-from-bestiary",
entry: BESTIARY_ENTRY,
});
const s2 = encounterReducer(s1, {
type: "add-from-bestiary",
entry: BESTIARY_ENTRY,
});
const names = s2.encounter.combatants.map((c) => c.name);
expect(names).toContain("Goblin 1");
expect(names).toContain("Goblin 2");
});
});
describe("add-multiple-from-bestiary", () => {
it("adds multiple creatures in one action", () => {
const next = encounterReducer(emptyState(), {
type: "add-multiple-from-bestiary",
entry: BESTIARY_ENTRY,
count: 3,
});
expect(next.encounter.combatants).toHaveLength(3);
expect(next.undoRedoState.undoStack).toHaveLength(1);
expect(next.lastCreatureId).toBe("mm:goblin");
});
});
describe("add-from-player-character", () => {
it("adds combatant with PC attributes", () => {
const pc: PlayerCharacter = {
id: playerCharacterId("pc-1"),
name: "Aria",
ac: 16,
maxHp: 30,
color: "blue",
icon: "sword",
};
const next = encounterReducer(emptyState(), {
type: "add-from-player-character",
pc,
});
const c = next.encounter.combatants[0];
expect(c.name).toBe("Aria");
expect(c.maxHp).toBe(30);
expect(c.ac).toBe(16);
expect(c.color).toBe("blue");
expect(c.icon).toBe("sword");
expect(c.playerCharacterId).toBe("pc-1");
expect(next.lastCreatureId).toBeNull();
});
});
describe("import", () => {
it("replaces encounter and undo/redo state", () => {
const state = stateWith("A", "B");
const enc = createEncounter([
{ id: combatantId("c-5"), name: "Imported" },
]);
if (isDomainError(enc)) throw new Error("Setup failed");
const next = encounterReducer(state, {
type: "import",
encounter: enc,
undoRedoState: EMPTY_UNDO_REDO_STATE,
});
expect(next.encounter.combatants).toHaveLength(1);
expect(next.encounter.combatants[0].name).toBe("Imported");
expect(next.nextId).toBe(5);
});
});
describe("events accumulation", () => {
it("accumulates events across actions", () => {
const s1 = encounterReducer(emptyState(), {
type: "add-combatant",
name: "A",
});
const s2 = encounterReducer(s1, {
type: "add-combatant",
name: "B",
});
expect(s2.events.length).toBeGreaterThanOrEqual(2);
});
});
});
@@ -0,0 +1,328 @@
// @vitest-environment jsdom
import type { PlayerCharacter } from "@initiative/domain";
import { playerCharacterId } from "@initiative/domain";
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SearchResult } from "../../contexts/bestiary-context.js";
import { useActionBarState } from "../use-action-bar-state.js";
const mockAddCombatant = vi.fn();
const mockAddFromBestiary = vi.fn();
const mockAddMultipleFromBestiary = vi.fn();
const mockAddFromPlayerCharacter = vi.fn();
const mockBestiarySearch = vi.fn<(q: string) => SearchResult[]>();
const mockShowCreature = vi.fn();
const mockShowBulkImport = vi.fn();
const mockShowSourceManager = vi.fn();
vi.mock("../../contexts/encounter-context.js", () => ({
useEncounterContext: () => ({
addCombatant: mockAddCombatant,
addFromBestiary: mockAddFromBestiary,
addMultipleFromBestiary: mockAddMultipleFromBestiary,
addFromPlayerCharacter: mockAddFromPlayerCharacter,
lastCreatureId: null,
}),
}));
vi.mock("../../contexts/bestiary-context.js", () => ({
useBestiaryContext: () => ({
search: mockBestiarySearch,
isLoaded: true,
}),
}));
vi.mock("../../contexts/player-characters-context.js", () => ({
usePlayerCharactersContext: () => ({
characters: mockPlayerCharacters,
}),
}));
vi.mock("../../contexts/side-panel-context.js", () => ({
useSidePanelContext: () => ({
showCreature: mockShowCreature,
showBulkImport: mockShowBulkImport,
showSourceManager: mockShowSourceManager,
panelView: { mode: "closed" },
}),
}));
let mockPlayerCharacters: PlayerCharacter[] = [];
const GOBLIN: SearchResult = {
name: "Goblin",
source: "MM",
sourceDisplayName: "Monster Manual",
ac: 15,
hp: 7,
dex: 14,
cr: "1/4",
initiativeProficiency: 0,
size: "Small",
type: "humanoid",
};
const ORC: SearchResult = {
name: "Orc",
source: "MM",
sourceDisplayName: "Monster Manual",
ac: 13,
hp: 15,
dex: 12,
cr: "1/2",
initiativeProficiency: 0,
size: "Medium",
type: "humanoid",
};
function renderActionBar() {
return renderHook(() => useActionBarState());
}
describe("useActionBarState", () => {
beforeEach(() => {
vi.clearAllMocks();
mockBestiarySearch.mockReturnValue([]);
mockPlayerCharacters = [];
});
describe("search and suggestions", () => {
it("starts with empty state", () => {
const { result } = renderActionBar();
expect(result.current.nameInput).toBe("");
expect(result.current.suggestions).toEqual([]);
expect(result.current.queued).toBeNull();
expect(result.current.browseMode).toBe(false);
});
it("searches bestiary when input >= 2 chars", () => {
mockBestiarySearch.mockReturnValue([GOBLIN]);
const { result } = renderActionBar();
act(() => result.current.handleNameChange("go"));
expect(mockBestiarySearch).toHaveBeenCalledWith("go");
expect(result.current.nameInput).toBe("go");
});
it("does not search when input < 2 chars", () => {
const { result } = renderActionBar();
act(() => result.current.handleNameChange("g"));
expect(mockBestiarySearch).not.toHaveBeenCalled();
});
it("matches player characters by name", () => {
mockPlayerCharacters = [
{
id: playerCharacterId("pc-1"),
name: "Gandalf",
ac: 15,
maxHp: 40,
},
];
mockBestiarySearch.mockReturnValue([]);
const { result } = renderActionBar();
act(() => result.current.handleNameChange("gan"));
expect(result.current.pcMatches).toHaveLength(1);
});
});
describe("queued creatures", () => {
it("queues a creature on click", () => {
const { result } = renderActionBar();
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
expect(result.current.queued).toEqual({
result: GOBLIN,
count: 1,
});
});
it("increments count when same creature clicked again", () => {
const { result } = renderActionBar();
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
expect(result.current.queued?.count).toBe(2);
});
it("resets queue when different creature clicked", () => {
const { result } = renderActionBar();
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
act(() => result.current.suggestionActions.clickSuggestion(ORC));
expect(result.current.queued).toEqual({
result: ORC,
count: 1,
});
});
it("confirmQueued calls addFromBestiary for count=1", () => {
const { result } = renderActionBar();
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
act(() => result.current.suggestionActions.confirmQueued());
expect(mockAddFromBestiary).toHaveBeenCalledWith(GOBLIN);
expect(result.current.queued).toBeNull();
});
it("confirmQueued calls addMultipleFromBestiary for count>1", () => {
const { result } = renderActionBar();
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
act(() => result.current.suggestionActions.confirmQueued());
expect(mockAddMultipleFromBestiary).toHaveBeenCalledWith(GOBLIN, 3);
});
it("clears queued when search text changes and creature no longer visible", () => {
mockBestiarySearch.mockReturnValue([GOBLIN]);
const { result } = renderActionBar();
act(() => result.current.handleNameChange("go"));
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
// Change search to something that won't match
mockBestiarySearch.mockReturnValue([]);
act(() => result.current.handleNameChange("xyz"));
expect(result.current.queued).toBeNull();
});
});
describe("form submission", () => {
it("adds custom combatant on submit", () => {
const { result } = renderActionBar();
act(() => result.current.handleNameChange("Fighter"));
const event = {
preventDefault: vi.fn(),
} as unknown as React.SubmitEvent<HTMLFormElement>;
act(() => result.current.handleAdd(event));
expect(mockAddCombatant).toHaveBeenCalledWith("Fighter", undefined);
expect(result.current.nameInput).toBe("");
});
it("does not add when name is empty", () => {
const { result } = renderActionBar();
const event = {
preventDefault: vi.fn(),
} as unknown as React.SubmitEvent<HTMLFormElement>;
act(() => result.current.handleAdd(event));
expect(mockAddCombatant).not.toHaveBeenCalled();
});
it("passes custom init/ac/maxHp when set", () => {
const { result } = renderActionBar();
act(() => result.current.handleNameChange("Fighter"));
act(() => result.current.setCustomInit("15"));
act(() => result.current.setCustomAc("18"));
act(() => result.current.setCustomMaxHp("45"));
const event = {
preventDefault: vi.fn(),
} as unknown as React.SubmitEvent<HTMLFormElement>;
act(() => result.current.handleAdd(event));
expect(mockAddCombatant).toHaveBeenCalledWith("Fighter", {
initiative: 15,
ac: 18,
maxHp: 45,
});
});
it("does not submit in browse mode", () => {
const { result } = renderActionBar();
act(() => result.current.toggleBrowseMode());
act(() => result.current.handleNameChange("Fighter"));
const event = {
preventDefault: vi.fn(),
} as unknown as React.SubmitEvent<HTMLFormElement>;
act(() => result.current.handleAdd(event));
expect(mockAddCombatant).not.toHaveBeenCalled();
});
it("confirms queued on submit instead of adding by name", () => {
const { result } = renderActionBar();
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
const event = {
preventDefault: vi.fn(),
} as unknown as React.SubmitEvent<HTMLFormElement>;
act(() => result.current.handleAdd(event));
expect(mockAddFromBestiary).toHaveBeenCalledWith(GOBLIN);
expect(mockAddCombatant).not.toHaveBeenCalled();
});
});
describe("browse mode", () => {
it("toggles browse mode", () => {
const { result } = renderActionBar();
act(() => result.current.toggleBrowseMode());
expect(result.current.browseMode).toBe(true);
act(() => result.current.toggleBrowseMode());
expect(result.current.browseMode).toBe(false);
});
it("handleBrowseSelect shows creature and exits browse mode", () => {
const { result } = renderActionBar();
act(() => result.current.toggleBrowseMode());
act(() => result.current.handleBrowseSelect(GOBLIN));
expect(mockShowCreature).toHaveBeenCalledWith("mm:goblin");
expect(result.current.browseMode).toBe(false);
expect(result.current.nameInput).toBe("");
});
});
describe("dismiss and clear", () => {
it("dismissSuggestions clears suggestions and queued", () => {
mockBestiarySearch.mockReturnValue([GOBLIN]);
const { result } = renderActionBar();
act(() => result.current.handleNameChange("go"));
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
act(() => result.current.suggestionActions.dismiss());
expect(result.current.queued).toBeNull();
expect(result.current.suggestionIndex).toBe(-1);
});
it("clear resets everything", () => {
mockBestiarySearch.mockReturnValue([GOBLIN]);
const { result } = renderActionBar();
act(() => result.current.handleNameChange("go"));
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
act(() => result.current.suggestionActions.clear());
expect(result.current.nameInput).toBe("");
expect(result.current.queued).toBeNull();
expect(result.current.suggestionIndex).toBe(-1);
});
});
});
@@ -0,0 +1,220 @@
// @vitest-environment jsdom
import type {
Combatant,
CreatureId,
Encounter,
PlayerCharacter,
} from "@initiative/domain";
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../contexts/encounter-context.js", () => ({
useEncounterContext: vi.fn(),
}));
vi.mock("../../contexts/player-characters-context.js", () => ({
usePlayerCharactersContext: vi.fn(),
}));
vi.mock("../../contexts/bestiary-context.js", () => ({
useBestiaryContext: vi.fn(),
}));
import { useBestiaryContext } from "../../contexts/bestiary-context.js";
import { useEncounterContext } from "../../contexts/encounter-context.js";
import { usePlayerCharactersContext } from "../../contexts/player-characters-context.js";
import { useDifficulty } from "../use-difficulty.js";
const mockEncounterContext = vi.mocked(useEncounterContext);
const mockPlayerCharactersContext = vi.mocked(usePlayerCharactersContext);
const mockBestiaryContext = vi.mocked(useBestiaryContext);
const pcId1 = playerCharacterId("pc-1");
const pcId2 = playerCharacterId("pc-2");
const crId1 = creatureId("creature-1");
const _crId2 = creatureId("creature-2");
function setup(options: {
combatants: Combatant[];
characters: PlayerCharacter[];
creatures: Map<CreatureId, { cr: string }>;
}) {
const encounter = {
combatants: options.combatants,
activeIndex: 0,
roundNumber: 1,
} as Encounter;
mockEncounterContext.mockReturnValue({
encounter,
} as ReturnType<typeof useEncounterContext>);
mockPlayerCharactersContext.mockReturnValue({
characters: options.characters,
} as ReturnType<typeof usePlayerCharactersContext>);
mockBestiaryContext.mockReturnValue({
getCreature: (id: CreatureId) => options.creatures.get(id),
} as ReturnType<typeof useBestiaryContext>);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("useDifficulty", () => {
it("returns difficulty result for leveled PCs and bestiary monsters", () => {
setup({
combatants: [
{ id: combatantId("c1"), name: "Hero", playerCharacterId: pcId1 },
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
],
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }],
creatures: new Map([[crId1, { cr: "1/4" }]]),
});
const { result } = renderHook(() => useDifficulty());
expect(result.current).not.toBeNull();
expect(result.current?.tier).toBe("low");
expect(result.current?.totalMonsterXp).toBe(50);
});
describe("returns null when data is insufficient (ED-2)", () => {
it("returns null when encounter has no combatants", () => {
setup({ combatants: [], characters: [], creatures: new Map() });
const { result } = renderHook(() => useDifficulty());
expect(result.current).toBeNull();
});
it("returns null when only custom combatants (no creatureId)", () => {
setup({
combatants: [
{
id: combatantId("c1"),
name: "Custom",
playerCharacterId: pcId1,
},
],
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }],
creatures: new Map(),
});
const { result } = renderHook(() => useDifficulty());
expect(result.current).toBeNull();
});
it("returns null when bestiary monsters present but no PC combatants", () => {
setup({
combatants: [
{ id: combatantId("c1"), name: "Goblin", creatureId: crId1 },
],
characters: [],
creatures: new Map([[crId1, { cr: "1" }]]),
});
const { result } = renderHook(() => useDifficulty());
expect(result.current).toBeNull();
});
it("returns null when PC combatants have no level", () => {
setup({
combatants: [
{
id: combatantId("c1"),
name: "Hero",
playerCharacterId: pcId1,
},
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
],
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }],
creatures: new Map([[crId1, { cr: "1" }]]),
});
const { result } = renderHook(() => useDifficulty());
expect(result.current).toBeNull();
});
it("returns null when PC combatant references unknown player character", () => {
setup({
combatants: [
{
id: combatantId("c1"),
name: "Hero",
playerCharacterId: pcId2,
},
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
],
characters: [{ id: pcId1, name: "Other", ac: 15, maxHp: 30, level: 5 }],
creatures: new Map([[crId1, { cr: "1" }]]),
});
const { result } = renderHook(() => useDifficulty());
expect(result.current).toBeNull();
});
});
it("handles mixed combatants: only leveled PCs and bestiary monsters contribute", () => {
// Party: one leveled PC, one without level (excluded)
// Monsters: one bestiary creature, one custom (excluded)
setup({
combatants: [
{
id: combatantId("c1"),
name: "Leveled",
playerCharacterId: pcId1,
},
{
id: combatantId("c2"),
name: "No Level",
playerCharacterId: pcId2,
},
{ id: combatantId("c3"), name: "Goblin", creatureId: crId1 },
{ id: combatantId("c4"), name: "Custom Monster" },
],
characters: [
{ id: pcId1, name: "Leveled", ac: 15, maxHp: 30, level: 1 },
{ id: pcId2, name: "No Level", ac: 12, maxHp: 20 },
],
creatures: new Map([[crId1, { cr: "1" }]]),
});
const { result } = renderHook(() => useDifficulty());
expect(result.current).not.toBeNull();
// 1 level-1 PC: budget low=50, mod=75, high=100
// 1 CR 1 monster: 200 XP → high (200 >= 100)
expect(result.current?.tier).toBe("high");
expect(result.current?.totalMonsterXp).toBe(200);
expect(result.current?.partyBudget.low).toBe(50);
});
it("includes duplicate PC combatants in budget", () => {
// Same PC added twice → counts twice
setup({
combatants: [
{
id: combatantId("c1"),
name: "Hero 1",
playerCharacterId: pcId1,
},
{
id: combatantId("c2"),
name: "Hero 2",
playerCharacterId: pcId1,
},
{ id: combatantId("c3"), name: "Goblin", creatureId: crId1 },
],
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }],
creatures: new Map([[crId1, { cr: "1/4" }]]),
});
const { result } = renderHook(() => useDifficulty());
expect(result.current).not.toBeNull();
// 2x level 1: budget low=100
expect(result.current?.partyBudget.low).toBe(100);
});
});
@@ -42,7 +42,14 @@ describe("usePlayerCharacters", () => {
const { result } = renderHook(() => usePlayerCharacters());
act(() => {
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
result.current.createCharacter(
"Vex",
15,
28,
undefined,
undefined,
undefined,
);
});
expect(result.current.characters).toHaveLength(1);
@@ -57,7 +64,14 @@ describe("usePlayerCharacters", () => {
let error: unknown;
act(() => {
error = result.current.createCharacter("", 15, 28, undefined, undefined);
error = result.current.createCharacter(
"",
15,
28,
undefined,
undefined,
undefined,
);
});
expect(error).toMatchObject({ kind: "domain-error" });
@@ -68,7 +82,14 @@ describe("usePlayerCharacters", () => {
const { result } = renderHook(() => usePlayerCharacters());
act(() => {
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
result.current.createCharacter(
"Vex",
15,
28,
undefined,
undefined,
undefined,
);
});
const id = result.current.characters[0].id;
@@ -85,7 +106,14 @@ describe("usePlayerCharacters", () => {
const { result } = renderHook(() => usePlayerCharacters());
act(() => {
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
result.current.createCharacter(
"Vex",
15,
28,
undefined,
undefined,
undefined,
);
});
const id = result.current.characters[0].id;
@@ -0,0 +1,45 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { useRulesEdition } from "../use-rules-edition.js";
const STORAGE_KEY = "initiative:rules-edition";
describe("useRulesEdition", () => {
afterEach(() => {
// Reset to default
const { result } = renderHook(() => useRulesEdition());
act(() => result.current.setEdition("5.5e"));
localStorage.removeItem(STORAGE_KEY);
});
it("defaults to 5.5e", () => {
const { result } = renderHook(() => useRulesEdition());
expect(result.current.edition).toBe("5.5e");
});
it("setEdition updates value", () => {
const { result } = renderHook(() => useRulesEdition());
act(() => result.current.setEdition("5e"));
expect(result.current.edition).toBe("5e");
});
it("setEdition persists to localStorage", () => {
const { result } = renderHook(() => useRulesEdition());
act(() => result.current.setEdition("5e"));
expect(localStorage.getItem(STORAGE_KEY)).toBe("5e");
});
it("multiple hooks stay in sync", () => {
const { result: r1 } = renderHook(() => useRulesEdition());
const { result: r2 } = renderHook(() => useRulesEdition());
act(() => r1.current.setEdition("5e"));
expect(r2.current.edition).toBe("5e");
});
});
@@ -0,0 +1,63 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { useTheme } from "../use-theme.js";
const STORAGE_KEY = "initiative:theme";
describe("useTheme", () => {
afterEach(() => {
// Reset to default
const { result } = renderHook(() => useTheme());
act(() => result.current.setPreference("system"));
localStorage.removeItem(STORAGE_KEY);
});
it("defaults to system preference", () => {
const { result } = renderHook(() => useTheme());
expect(result.current.preference).toBe("system");
});
it("setPreference updates to light", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setPreference("light"));
expect(result.current.preference).toBe("light");
expect(result.current.resolved).toBe("light");
});
it("setPreference updates to dark", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setPreference("dark"));
expect(result.current.preference).toBe("dark");
expect(result.current.resolved).toBe("dark");
});
it("persists preference to localStorage", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setPreference("light"));
expect(localStorage.getItem(STORAGE_KEY)).toBe("light");
});
it("applies theme to document element", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setPreference("light"));
expect(document.documentElement.dataset.theme).toBe("light");
});
it("multiple hooks stay in sync", () => {
const { result: r1 } = renderHook(() => useTheme());
const { result: r2 } = renderHook(() => useTheme());
act(() => r1.current.setPreference("dark"));
expect(r2.current.preference).toBe("dark");
});
});
+323
View File
@@ -0,0 +1,323 @@
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
import {
useCallback,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type { SearchResult } from "../contexts/bestiary-context.js";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useEncounterContext } from "../contexts/encounter-context.js";
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js";
export interface QueuedCreature {
result: SearchResult;
count: number;
}
export interface SuggestionActions {
dismiss: () => void;
clear: () => void;
clickSuggestion: (result: SearchResult) => void;
setSuggestionIndex: (i: number) => void;
setQueued: (q: QueuedCreature | null) => void;
confirmQueued: () => void;
addFromPlayerCharacter?: (pc: PlayerCharacter) => void;
}
export function creatureKey(r: SearchResult): string {
return `${r.source}:${r.name}`;
}
export function useActionBarState() {
const {
addCombatant,
addFromBestiary,
addMultipleFromBestiary,
addFromPlayerCharacter,
lastCreatureId,
} = useEncounterContext();
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
useBestiaryContext();
const { characters: playerCharacters } = usePlayerCharactersContext();
const { showBulkImport, showSourceManager, showCreature, panelView } =
useSidePanelContext();
// Auto-show stat block when a bestiary creature is added on desktop
const prevCreatureIdRef = useRef(lastCreatureId);
useEffect(() => {
if (
lastCreatureId &&
lastCreatureId !== prevCreatureIdRef.current &&
panelView.mode === "closed" &&
globalThis.matchMedia("(min-width: 1024px)").matches
) {
showCreature(lastCreatureId);
}
prevCreatureIdRef.current = lastCreatureId;
}, [lastCreatureId, panelView.mode, showCreature]);
const [nameInput, setNameInput] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
const deferredSuggestions = useDeferredValue(suggestions);
const deferredPcMatches = useDeferredValue(pcMatches);
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 = useCallback(() => {
setNameInput("");
setSuggestions([]);
setPcMatches([]);
setQueued(null);
setSuggestionIndex(-1);
}, []);
const dismissSuggestions = useCallback(() => {
setSuggestions([]);
setPcMatches([]);
setQueued(null);
setSuggestionIndex(-1);
}, []);
const handleAddFromBestiary = useCallback(
(result: SearchResult) => {
addFromBestiary(result);
},
[addFromBestiary],
);
const handleViewStatBlock = useCallback(
(result: SearchResult) => {
const slug = result.name
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/(^-|-$)/g, "");
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
showCreature(cId);
},
[showCreature],
);
const confirmQueued = useCallback(() => {
if (!queued) return;
if (queued.count === 1) {
handleAddFromBestiary(queued.result);
} else {
addMultipleFromBestiary(queued.result, queued.count);
}
clearInput();
}, [queued, handleAddFromBestiary, addMultipleFromBestiary, 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: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
if (browseMode) return;
if (queued) {
confirmQueued();
return;
}
if (nameInput.trim() === "") return;
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;
addCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
setNameInput("");
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);
if (browseMode) {
handleBrowseSearch(value);
} else {
handleAddSearch(value);
}
};
const handleClickSuggestion = useCallback((result: SearchResult) => {
const key = creatureKey(result);
setQueued((prev) => {
if (prev && creatureKey(prev.result) === key) {
return { ...prev, count: prev.count + 1 };
}
return { result, count: 1 };
});
}, []);
const handleEnter = () => {
if (queued) {
confirmQueued();
} else if (suggestionIndex >= 0) {
handleClickSuggestion(suggestions[suggestionIndex]);
}
};
const hasSuggestions =
deferredSuggestions.length > 0 || deferredPcMatches.length > 0;
const handleKeyDown = (e: React.KeyboardEvent) => {
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));
} 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();
handleViewStatBlock(suggestions[suggestionIndex]);
setBrowseMode(false);
clearInput();
}
};
const handleBrowseSelect = (result: SearchResult) => {
handleViewStatBlock(result);
setBrowseMode(false);
clearInput();
};
const toggleBrowseMode = () => {
setBrowseMode((prev) => {
const next = !prev;
setSuggestionIndex(-1);
setQueued(null);
if (next) {
handleBrowseSearch(nameInput);
} else {
handleAddSearch(nameInput);
}
return next;
});
clearCustomFields();
};
const suggestionActions: SuggestionActions = useMemo(
() => ({
dismiss: dismissSuggestions,
clear: clearInput,
clickSuggestion: handleClickSuggestion,
setSuggestionIndex,
setQueued,
confirmQueued,
addFromPlayerCharacter,
}),
[
dismissSuggestions,
clearInput,
handleClickSuggestion,
confirmQueued,
addFromPlayerCharacter,
],
);
return {
// State
nameInput,
suggestions: deferredSuggestions,
pcMatches: deferredPcMatches,
suggestionIndex,
queued,
customInit,
customAc,
customMaxHp,
browseMode,
bestiaryLoaded,
hasSuggestions,
showBulkImport,
showSourceManager,
// Actions
suggestionActions,
handleNameChange,
handleKeyDown,
handleBrowseKeyDown,
handleAdd,
handleBrowseSelect,
toggleBrowseMode,
setCustomInit,
setCustomAc,
setCustomMaxHp,
} as const;
}
+13 -3
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { useEncounterContext } from "../contexts/encounter-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js";
@@ -8,10 +8,20 @@ export function useAutoStatBlock(): void {
const activeCreatureId =
encounter.combatants[encounter.activeIndex]?.creatureId;
const prevActiveIndexRef = useRef(encounter.activeIndex);
useEffect(() => {
if (activeCreatureId && panelView.mode === "creature") {
const prevIndex = prevActiveIndexRef.current;
prevActiveIndexRef.current = encounter.activeIndex;
// Only auto-update when the active turn changes (advance/retreat),
// not when the panel mode changes (user clicking a different creature).
if (
encounter.activeIndex !== prevIndex &&
activeCreatureId &&
panelView.mode === "creature"
) {
updateCreature(activeCreatureId);
}
}, [activeCreatureId, panelView.mode, updateCreature]);
}, [encounter.activeIndex, activeCreatureId, panelView.mode, updateCreature]);
}
+27
View File
@@ -0,0 +1,27 @@
import type { RefObject } from "react";
import { useEffect } from "react";
export function useClickOutside(
ref: RefObject<HTMLElement | null>,
onClose: () => void,
active = true,
): void {
useEffect(() => {
if (!active) return;
function handleMouseDown(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [ref, onClose, active]);
}
+54
View File
@@ -0,0 +1,54 @@
import type {
Combatant,
CreatureId,
DifficultyResult,
PlayerCharacter,
} from "@initiative/domain";
import { calculateEncounterDifficulty } from "@initiative/domain";
import { useMemo } from "react";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useEncounterContext } from "../contexts/encounter-context.js";
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
function derivePartyLevels(
combatants: readonly Combatant[],
characters: readonly PlayerCharacter[],
): number[] {
const levels: number[] = [];
for (const c of combatants) {
if (!c.playerCharacterId) continue;
const pc = characters.find((p) => p.id === c.playerCharacterId);
if (pc?.level !== undefined) levels.push(pc.level);
}
return levels;
}
function deriveMonsterCrs(
combatants: readonly Combatant[],
getCreature: (id: CreatureId) => { cr: string } | undefined,
): string[] {
const crs: string[] = [];
for (const c of combatants) {
if (!c.creatureId) continue;
const creature = getCreature(c.creatureId);
if (creature) crs.push(creature.cr);
}
return crs;
}
export function useDifficulty(): DifficultyResult | null {
const { encounter } = useEncounterContext();
const { characters } = usePlayerCharactersContext();
const { getCreature } = useBestiaryContext();
return useMemo(() => {
const partyLevels = derivePartyLevels(encounter.combatants, characters);
const monsterCrs = deriveMonsterCrs(encounter.combatants, getCreature);
if (partyLevels.length === 0 || monsterCrs.length === 0) {
return null;
}
return calculateEncounterDifficulty(partyLevels, monsterCrs);
}, [encounter.combatants, characters, getCreature]);
}
+439 -309
View File
@@ -1,10 +1,11 @@
import type { EncounterStore } from "@initiative/application";
import type { EncounterStore, UndoRedoStore } from "@initiative/application";
import {
addCombatantUseCase,
adjustHpUseCase,
advanceTurnUseCase,
clearEncounterUseCase,
editCombatantUseCase,
redoUseCase,
removeCombatantUseCase,
retreatTurnUseCase,
setAcUseCase,
@@ -13,27 +14,82 @@ import {
setTempHpUseCase,
toggleConcentrationUseCase,
toggleConditionUseCase,
undoUseCase,
} from "@initiative/application";
import type {
BestiaryIndexEntry,
CombatantId,
CombatantInit,
ConditionId,
CreatureId,
DomainError,
DomainEvent,
Encounter,
PlayerCharacter,
UndoRedoState,
} from "@initiative/domain";
import {
clearHistory,
combatantId,
isDomainError,
creatureId as makeCreatureId,
pushUndo,
resolveCreatureName,
} from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useReducer, useRef } from "react";
import {
loadEncounter,
saveEncounter,
} from "../persistence/encounter-storage.js";
import {
loadUndoRedoStacks,
saveUndoRedoStacks,
} from "../persistence/undo-redo-storage.js";
// -- Types --
type EncounterAction =
| { type: "advance-turn" }
| { type: "retreat-turn" }
| { type: "add-combatant"; name: string; init?: CombatantInit }
| { type: "remove-combatant"; id: CombatantId }
| { type: "edit-combatant"; id: CombatantId; newName: string }
| { type: "set-initiative"; id: CombatantId; value: number | undefined }
| { type: "set-hp"; id: CombatantId; maxHp: number | undefined }
| { type: "adjust-hp"; id: CombatantId; delta: number }
| { type: "set-temp-hp"; id: CombatantId; tempHp: number | undefined }
| { type: "set-ac"; id: CombatantId; value: number | undefined }
| {
type: "toggle-condition";
id: CombatantId;
conditionId: ConditionId;
}
| { type: "toggle-concentration"; id: CombatantId }
| { type: "clear-encounter" }
| { type: "undo" }
| { type: "redo" }
| { type: "add-from-bestiary"; entry: BestiaryIndexEntry }
| {
type: "add-multiple-from-bestiary";
entry: BestiaryIndexEntry;
count: number;
}
| { type: "add-from-player-character"; pc: PlayerCharacter }
| {
type: "import";
encounter: Encounter;
undoRedoState: UndoRedoState;
};
export interface EncounterState {
readonly encounter: Encounter;
readonly undoRedoState: UndoRedoState;
readonly events: readonly DomainEvent[];
readonly nextId: number;
readonly lastCreatureId: CreatureId | null;
}
// -- Initialization --
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
@@ -43,12 +99,6 @@ const EMPTY_ENCOUNTER: Encounter = {
roundNumber: 1,
};
function initializeEncounter(): Encounter {
const stored = loadEncounter();
if (stored !== null) return stored;
return EMPTY_ENCOUNTER;
}
function deriveNextId(encounter: Encounter): number {
let max = 0;
for (const c of encounter.combatants) {
@@ -61,339 +111,332 @@ 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[]>([]);
const encounterRef = useRef(encounter);
encounterRef.current = encounter;
useEffect(() => {
saveEncounter(encounter);
}, [encounter]);
const makeStore = useCallback((): EncounterStore => {
function initializeState(): EncounterState {
const encounter = loadEncounter() ?? EMPTY_ENCOUNTER;
return {
get: () => encounterRef.current,
save: (e) => {
encounterRef.current = e;
setEncounter(e);
},
encounter,
undoRedoState: loadUndoRedoStacks(),
events: [],
nextId: deriveNextId(encounter),
lastCreatureId: null,
};
}, []);
const advanceTurn = useCallback(() => {
const result = advanceTurnUseCase(makeStore());
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
}, [makeStore]);
// -- Helpers --
const retreatTurn = useCallback(() => {
const result = retreatTurnUseCase(makeStore());
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
}, [makeStore]);
const nextId = useRef(deriveNextId(encounter));
const addCombatant = useCallback(
(name: string, opts?: CombatantOpts) => {
const id = combatantId(`c-${++nextId.current}`);
const result = addCombatantUseCase(makeStore(), id, name);
if (isDomainError(result)) {
return;
}
if (opts) {
const optEvents = applyCombatantOpts(makeStore, id, opts);
if (optEvents.length > 0) {
setEvents((prev) => [...prev, ...optEvents]);
}
}
setEvents((prev) => [...prev, ...result]);
function makeStoreFromState(state: EncounterState): {
store: EncounterStore;
getEncounter: () => Encounter;
} {
let current = state.encounter;
return {
store: {
get: () => current,
save: (e) => {
current = e;
},
[makeStore],
);
const removeCombatant = useCallback(
(id: CombatantId) => {
const result = removeCombatantUseCase(makeStore(), id);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
const editCombatant = useCallback(
(id: CombatantId, newName: string) => {
const result = editCombatantUseCase(makeStore(), id, newName);
if (isDomainError(result)) {
return;
getEncounter: () => current,
};
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
const setInitiative = useCallback(
(id: CombatantId, value: number | undefined) => {
const result = setInitiativeUseCase(makeStore(), id, value);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
const setHp = useCallback(
(id: CombatantId, maxHp: number | undefined) => {
const result = setHpUseCase(makeStore(), id, maxHp);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
const adjustHp = useCallback(
(id: CombatantId, delta: number) => {
const result = adjustHpUseCase(makeStore(), id, delta);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
const setTempHp = useCallback(
(id: CombatantId, tempHp: number | undefined) => {
const result = setTempHpUseCase(makeStore(), id, tempHp);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
const setAc = useCallback(
(id: CombatantId, value: number | undefined) => {
const result = setAcUseCase(makeStore(), id, value);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
const toggleCondition = useCallback(
(id: CombatantId, conditionId: ConditionId) => {
const result = toggleConditionUseCase(makeStore(), id, conditionId);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
const toggleConcentration = useCallback(
(id: CombatantId) => {
const result = toggleConcentrationUseCase(makeStore(), id);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
const clearEncounter = useCallback(() => {
const result = clearEncounterUseCase(makeStore());
if (isDomainError(result)) {
return;
}
nextId.current = 0;
setEvents((prev) => [...prev, ...result]);
}, [makeStore]);
const addFromBestiary = useCallback(
(entry: BestiaryIndexEntry): CreatureId | null => {
const store = makeStore();
function resolveAndRename(store: EncounterStore, name: string): string {
const existingNames = store.get().combatants.map((c) => c.name);
const { newName, renames } = resolveCreatureName(
entry.name,
existingNames,
);
const { newName, renames } = resolveCreatureName(name, existingNames);
// Apply renames (e.g., "Goblin" → "Goblin 1")
for (const { from, to } of renames) {
const target = store.get().combatants.find((c) => c.name === from);
if (target) {
editCombatantUseCase(makeStore(), target.id, to);
editCombatantUseCase(store, target.id, to);
}
}
// Add combatant with resolved name
const id = combatantId(`c-${++nextId.current}`);
const addResult = addCombatantUseCase(makeStore(), id, newName);
if (isDomainError(addResult)) return null;
// Set HP
const hpResult = setHpUseCase(makeStore(), id, entry.hp);
if (!isDomainError(hpResult)) {
setEvents((prev) => [...prev, ...hpResult]);
return newName;
}
// Set AC
if (entry.ac > 0) {
const acResult = setAcUseCase(makeStore(), id, entry.ac);
if (!isDomainError(acResult)) {
setEvents((prev) => [...prev, ...acResult]);
}
}
function addOneFromBestiary(
store: EncounterStore,
entry: BestiaryIndexEntry,
nextId: number,
): {
cId: CreatureId;
events: DomainEvent[];
nextId: number;
} | null {
const newName = resolveAndRename(store, entry.name);
// Derive creatureId from source + name
const slug = entry.name
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/(^-|-$)/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();
store.save({
...currentEncounter,
combatants: currentEncounter.combatants.map((c) =>
c.id === id ? { ...c, creatureId: cId } : c,
),
const id = combatantId(`c-${nextId + 1}`);
const result = addCombatantUseCase(store, id, newName, {
maxHp: entry.hp,
ac: entry.ac > 0 ? entry.ac : undefined,
creatureId: cId,
});
setEvents((prev) => [...prev, ...addResult]);
if (isDomainError(result)) return null;
return cId;
return { cId, events: result, nextId: nextId + 1 };
}
// -- Reducer case handlers --
function handleUndoRedo(
state: EncounterState,
direction: "undo" | "redo",
): EncounterState {
const { store, getEncounter } = makeStoreFromState(state);
const undoRedoStore: UndoRedoStore = {
get: () => state.undoRedoState,
save: () => {},
};
const applyFn = direction === "undo" ? undoUseCase : redoUseCase;
const result = applyFn(store, undoRedoStore);
if (isDomainError(result)) return state;
const isUndo = direction === "undo";
return {
...state,
encounter: getEncounter(),
undoRedoState: {
undoStack: isUndo
? state.undoRedoState.undoStack.slice(0, -1)
: [...state.undoRedoState.undoStack, state.encounter],
redoStack: isUndo
? [...state.undoRedoState.redoStack, state.encounter]
: state.undoRedoState.redoStack.slice(0, -1),
},
[makeStore],
);
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;
function handleAddFromBestiary(
state: EncounterState,
entry: BestiaryIndexEntry,
count: number,
): EncounterState {
const { store, getEncounter } = makeStoreFromState(state);
const allEvents: DomainEvent[] = [];
let nextId = state.nextId;
let lastCId: CreatureId | null = null;
// Set HP
const hpResult = setHpUseCase(makeStore(), id, pc.maxHp);
if (!isDomainError(hpResult)) {
setEvents((prev) => [...prev, ...hpResult]);
for (let i = 0; i < count; i++) {
const added = addOneFromBestiary(store, entry, nextId);
if (!added) return state;
allEvents.push(...added.events);
nextId = added.nextId;
lastCId = added.cId;
}
// Set AC
if (pc.ac > 0) {
const acResult = setAcUseCase(makeStore(), id, pc.ac);
if (!isDomainError(acResult)) {
setEvents((prev) => [...prev, ...acResult]);
}
return {
...state,
encounter: getEncounter(),
undoRedoState: pushUndo(state.undoRedoState, state.encounter),
events: [...state.events, ...allEvents],
nextId,
lastCreatureId: lastCId,
};
}
// Set color, icon, and playerCharacterId on the combatant
const currentEncounter = store.get();
store.save({
...currentEncounter,
combatants: currentEncounter.combatants.map((c) =>
c.id === id
? {
...c,
function handleAddFromPlayerCharacter(
state: EncounterState,
pc: PlayerCharacter,
): EncounterState {
const { store, getEncounter } = makeStoreFromState(state);
const newName = resolveAndRename(store, pc.name);
const id = combatantId(`c-${state.nextId + 1}`);
const result = addCombatantUseCase(store, id, newName, {
maxHp: pc.maxHp,
ac: pc.ac > 0 ? pc.ac : undefined,
color: pc.color,
icon: pc.icon,
playerCharacterId: pc.id,
}
: c,
),
});
if (isDomainError(result)) return state;
return {
...state,
encounter: getEncounter(),
undoRedoState: pushUndo(state.undoRedoState, state.encounter),
events: [...state.events, ...result],
nextId: state.nextId + 1,
lastCreatureId: null,
};
}
setEvents((prev) => [...prev, ...addResult]);
// -- Reducer --
export function encounterReducer(
state: EncounterState,
action: EncounterAction,
): EncounterState {
switch (action.type) {
case "import":
return {
...state,
encounter: action.encounter,
undoRedoState: action.undoRedoState,
nextId: deriveNextId(action.encounter),
lastCreatureId: null,
};
case "undo":
case "redo":
return handleUndoRedo(state, action.type);
case "clear-encounter": {
const { store, getEncounter } = makeStoreFromState(state);
const result = clearEncounterUseCase(store);
if (isDomainError(result)) return state;
return {
...state,
encounter: getEncounter(),
undoRedoState: clearHistory(),
events: [...state.events, ...result],
nextId: 0,
lastCreatureId: null,
};
}
case "add-from-bestiary":
return handleAddFromBestiary(state, action.entry, 1);
case "add-multiple-from-bestiary":
return handleAddFromBestiary(state, action.entry, action.count);
case "add-from-player-character":
return handleAddFromPlayerCharacter(state, action.pc);
default:
return dispatchEncounterAction(state, action);
}
}
function dispatchEncounterAction(
state: EncounterState,
action: Extract<
EncounterAction,
| { type: "advance-turn" }
| { type: "retreat-turn" }
| { type: "add-combatant" }
| { type: "remove-combatant" }
| { type: "edit-combatant" }
| { type: "set-initiative" }
| { type: "set-hp" }
| { type: "adjust-hp" }
| { type: "set-temp-hp" }
| { type: "set-ac" }
| { type: "toggle-condition" }
| { type: "toggle-concentration" }
>,
): EncounterState {
const { store, getEncounter } = makeStoreFromState(state);
let result: DomainEvent[] | DomainError;
switch (action.type) {
case "advance-turn":
result = advanceTurnUseCase(store);
break;
case "retreat-turn":
result = retreatTurnUseCase(store);
break;
case "add-combatant": {
const id = combatantId(`c-${state.nextId + 1}`);
result = addCombatantUseCase(store, id, action.name, action.init);
break;
}
case "remove-combatant":
result = removeCombatantUseCase(store, action.id);
break;
case "edit-combatant":
result = editCombatantUseCase(store, action.id, action.newName);
break;
case "set-initiative":
result = setInitiativeUseCase(store, action.id, action.value);
break;
case "set-hp":
result = setHpUseCase(store, action.id, action.maxHp);
break;
case "adjust-hp":
result = adjustHpUseCase(store, action.id, action.delta);
break;
case "set-temp-hp":
result = setTempHpUseCase(store, action.id, action.tempHp);
break;
case "set-ac":
result = setAcUseCase(store, action.id, action.value);
break;
case "toggle-condition":
result = toggleConditionUseCase(store, action.id, action.conditionId);
break;
case "toggle-concentration":
result = toggleConcentrationUseCase(store, action.id);
break;
}
if (isDomainError(result)) return state;
return {
...state,
encounter: getEncounter(),
undoRedoState: pushUndo(state.undoRedoState, state.encounter),
events: [...state.events, ...result],
nextId: action.type === "add-combatant" ? state.nextId + 1 : state.nextId,
lastCreatureId: null,
};
}
// -- Hook --
export function useEncounter() {
const [state, dispatch] = useReducer(encounterReducer, null, initializeState);
const { encounter, undoRedoState, events } = state;
const encounterRef = useRef(encounter);
encounterRef.current = encounter;
const undoRedoRef = useRef(undoRedoState);
undoRedoRef.current = undoRedoState;
useEffect(() => {
saveEncounter(encounter);
}, [encounter]);
useEffect(() => {
saveUndoRedoStacks(undoRedoState);
}, [undoRedoState]);
// Escape hatches for useInitiativeRolls (needs raw port access)
const makeStore = useCallback((): EncounterStore => {
return {
get: () => encounterRef.current,
save: (e) => {
encounterRef.current = e;
dispatch({
type: "import",
encounter: e,
undoRedoState: undoRedoRef.current,
});
},
[makeStore],
);
};
}, []);
const withUndo = useCallback(<T>(action: () => T): T => {
const snapshot = encounterRef.current;
const result = action();
if (!isDomainError(result)) {
const newState = pushUndo(undoRedoRef.current, snapshot);
undoRedoRef.current = newState;
dispatch({
type: "import",
encounter: encounterRef.current,
undoRedoState: newState,
});
}
return result;
}, []);
// Derived state
const canUndo = undoRedoState.undoStack.length > 0;
const canRedo = undoRedoState.redoStack.length > 0;
const hasTempHp = encounter.combatants.some(
(c) => c.tempHp !== undefined && c.tempHp > 0,
);
const isEmpty = encounter.combatants.length === 0;
const hasCreatureCombatants = encounter.combatants.some(
(c) => c.creatureId != null,
@@ -404,26 +447,113 @@ export function useEncounter() {
return {
encounter,
undoRedoState,
events,
isEmpty,
hasTempHp,
hasCreatureCombatants,
canRollAllInitiative,
advanceTurn,
retreatTurn,
addCombatant,
clearEncounter,
removeCombatant,
editCombatant,
setInitiative,
setHp,
adjustHp,
setTempHp,
setAc,
toggleCondition,
toggleConcentration,
addFromBestiary,
addFromPlayerCharacter,
canUndo,
canRedo,
advanceTurn: useCallback(() => dispatch({ type: "advance-turn" }), []),
retreatTurn: useCallback(() => dispatch({ type: "retreat-turn" }), []),
addCombatant: useCallback(
(name: string, init?: CombatantInit) =>
dispatch({ type: "add-combatant", name, init }),
[],
),
removeCombatant: useCallback(
(id: CombatantId) => dispatch({ type: "remove-combatant", id }),
[],
),
editCombatant: useCallback(
(id: CombatantId, newName: string) =>
dispatch({ type: "edit-combatant", id, newName }),
[],
),
setInitiative: useCallback(
(id: CombatantId, value: number | undefined) =>
dispatch({ type: "set-initiative", id, value }),
[],
),
setHp: useCallback(
(id: CombatantId, maxHp: number | undefined) =>
dispatch({ type: "set-hp", id, maxHp }),
[],
),
adjustHp: useCallback(
(id: CombatantId, delta: number) =>
dispatch({ type: "adjust-hp", id, delta }),
[],
),
setTempHp: useCallback(
(id: CombatantId, tempHp: number | undefined) =>
dispatch({ type: "set-temp-hp", id, tempHp }),
[],
),
setAc: useCallback(
(id: CombatantId, value: number | undefined) =>
dispatch({ type: "set-ac", id, value }),
[],
),
toggleCondition: useCallback(
(id: CombatantId, conditionId: ConditionId) =>
dispatch({ type: "toggle-condition", id, conditionId }),
[],
),
toggleConcentration: useCallback(
(id: CombatantId) => dispatch({ type: "toggle-concentration", id }),
[],
),
clearEncounter: useCallback(
() => dispatch({ type: "clear-encounter" }),
[],
),
addFromBestiary: useCallback(
(entry: BestiaryIndexEntry): CreatureId | null => {
dispatch({ type: "add-from-bestiary", entry });
return null;
},
[],
),
addMultipleFromBestiary: useCallback(
(entry: BestiaryIndexEntry, count: number): CreatureId | null => {
dispatch({
type: "add-multiple-from-bestiary",
entry,
count,
});
return null;
},
[],
),
addFromPlayerCharacter: useCallback(
(pc: PlayerCharacter) =>
dispatch({ type: "add-from-player-character", pc }),
[],
),
undo: useCallback(() => dispatch({ type: "undo" }), []),
redo: useCallback(() => dispatch({ type: "redo" }), []),
setEncounter: useCallback(
(enc: Encounter) =>
dispatch({
type: "import",
encounter: enc,
undoRedoState: undoRedoRef.current,
}),
[],
),
setUndoRedoState: useCallback(
(urs: UndoRedoState) =>
dispatch({
type: "import",
encounter: encounterRef.current,
undoRedoState: urs,
}),
[],
),
makeStore,
withUndo,
lastCreatureId: state.lastCreatureId,
} as const;
}
+7 -14
View File
@@ -17,7 +17,7 @@ function rollDice(): number {
}
export function useInitiativeRolls() {
const { encounter, makeStore } = useEncounterContext();
const { encounter, makeStore, withUndo } = useEncounterContext();
const { getCreature } = useBestiaryContext();
const { showCreature } = useSidePanelContext();
@@ -28,12 +28,8 @@ export function useInitiativeRolls() {
(id: CombatantId, mode: RollMode = "normal") => {
const diceRolls: [number, ...number[]] =
mode === "normal" ? [rollDice()] : [rollDice(), rollDice()];
const result = rollInitiativeUseCase(
makeStore(),
id,
diceRolls,
getCreature,
mode,
const result = withUndo(() =>
rollInitiativeUseCase(makeStore(), id, diceRolls, getCreature, mode),
);
if (isDomainError(result)) {
setRollSingleSkipped(true);
@@ -43,22 +39,19 @@ export function useInitiativeRolls() {
}
}
},
[makeStore, getCreature, encounter.combatants, showCreature],
[makeStore, getCreature, withUndo, encounter.combatants, showCreature],
);
const handleRollAllInitiative = useCallback(
(mode: RollMode = "normal") => {
const result = rollAllInitiativeUseCase(
makeStore(),
rollDice,
getCreature,
mode,
const result = withUndo(() =>
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature, mode),
);
if (!isDomainError(result) && result.skippedNoSource > 0) {
setRollSkippedCount(result.skippedNoSource);
}
},
[makeStore, getCreature],
[makeStore, getCreature, withUndo],
);
return {
@@ -28,6 +28,7 @@ interface EditFields {
readonly maxHp?: number;
readonly color?: string | null;
readonly icon?: string | null;
readonly level?: number | null;
}
export function usePlayerCharacters() {
@@ -57,6 +58,7 @@ export function usePlayerCharacters() {
maxHp: number,
color: string | undefined,
icon: string | undefined,
level: number | undefined,
) => {
const id = generatePcId();
const result = createPlayerCharacterUseCase(
@@ -67,6 +69,7 @@ export function usePlayerCharacters() {
maxHp,
color,
icon,
level,
);
if (isDomainError(result)) {
return result;
@@ -103,6 +106,7 @@ export function usePlayerCharacters() {
createCharacter,
editCharacter,
deleteCharacter,
replacePlayerCharacters: setCharacters,
makeStore,
} as const;
}
+52
View File
@@ -0,0 +1,52 @@
import type { RulesEdition } from "@initiative/domain";
import { useCallback, useSyncExternalStore } from "react";
const STORAGE_KEY = "initiative:rules-edition";
const listeners = new Set<() => void>();
let currentEdition: RulesEdition = loadEdition();
function loadEdition(): RulesEdition {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === "5e" || raw === "5.5e") return raw;
} catch {
// storage unavailable
}
return "5.5e";
}
function saveEdition(edition: RulesEdition): void {
try {
localStorage.setItem(STORAGE_KEY, edition);
} catch {
// quota exceeded or storage unavailable
}
}
function notifyAll(): void {
for (const listener of listeners) {
listener();
}
}
function subscribe(callback: () => void): () => void {
listeners.add(callback);
return () => listeners.delete(callback);
}
function getSnapshot(): RulesEdition {
return currentEdition;
}
export function useRulesEdition() {
const edition = useSyncExternalStore(subscribe, getSnapshot);
const setEdition = useCallback((next: RulesEdition) => {
currentEdition = next;
saveEdition(next);
notifyAll();
}, []);
return { edition, setEdition } as const;
}
+4 -9
View File
@@ -39,6 +39,9 @@ function resolve(pref: ThemePreference): ResolvedTheme {
function applyTheme(resolved: ResolvedTheme): void {
document.documentElement.dataset.theme = resolved;
document
.querySelector('meta[name="theme-color"]')
?.setAttribute("content", resolved === "light" ? "#eeecea" : "#0e1a2e");
}
function notifyAll(): void {
@@ -71,8 +74,6 @@ function getSnapshot(): ThemePreference {
return currentPreference;
}
const CYCLE: ThemePreference[] = ["system", "light", "dark"];
export function useTheme() {
const preference = useSyncExternalStore(subscribe, getSnapshot);
const resolved = resolve(preference);
@@ -88,11 +89,5 @@ export function useTheme() {
notifyAll();
}, []);
const cycleTheme = useCallback(() => {
const idx = CYCLE.indexOf(currentPreference);
const next = CYCLE[(idx + 1) % CYCLE.length];
setPreference(next);
}, [setPreference]);
return { preference, resolved, setPreference, cycleTheme } as const;
return { preference, resolved, setPreference } as const;
}
@@ -0,0 +1,42 @@
import { useEffect } from "react";
const SUPPRESSED_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]);
function isTextInputFocused(): boolean {
const active = document.activeElement;
if (!active) return false;
if (SUPPRESSED_TAGS.has(active.tagName)) return true;
return active instanceof HTMLElement && active.isContentEditable;
}
function isUndoShortcut(e: KeyboardEvent): boolean {
return (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && !e.shiftKey;
}
function isRedoShortcut(e: KeyboardEvent): boolean {
return (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && e.shiftKey;
}
export function useUndoRedoShortcuts(
undo: () => void,
redo: () => void,
canUndo: boolean,
canRedo: boolean,
): void {
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (isTextInputFocused()) return;
if (isUndoShortcut(e) && canUndo) {
e.preventDefault();
undo();
} else if (isRedoShortcut(e) && canRedo) {
e.preventDefault();
redo();
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [undo, redo, canUndo, canRedo]);
}
+3
View File
@@ -7,6 +7,7 @@ import {
EncounterProvider,
InitiativeRollsProvider,
PlayerCharactersProvider,
RulesEditionProvider,
SidePanelProvider,
ThemeProvider,
} from "./contexts/index.js";
@@ -17,6 +18,7 @@ if (root) {
createRoot(root).render(
<StrictMode>
<ThemeProvider>
<RulesEditionProvider>
<EncounterProvider>
<BestiaryProvider>
<PlayerCharactersProvider>
@@ -30,6 +32,7 @@ if (root) {
</PlayerCharactersProvider>
</BestiaryProvider>
</EncounterProvider>
</RulesEditionProvider>
</ThemeProvider>
</StrictMode>,
);
@@ -122,64 +122,7 @@ describe("loadEncounter", () => {
expect(loadEncounter()).toBeNull();
});
// US3: Corrupt data scenarios
it("returns null for non-object JSON (string)", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify("hello"));
expect(loadEncounter()).toBeNull();
});
it("returns null for non-object JSON (number)", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(42));
expect(loadEncounter()).toBeNull();
});
it("returns null for non-object JSON (array)", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify([1, 2, 3]));
expect(loadEncounter()).toBeNull();
});
it("returns null for non-object JSON (null)", () => {
localStorage.setItem(STORAGE_KEY, "null");
expect(loadEncounter()).toBeNull();
});
it("returns null when combatants is a string instead of array", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: "not-array",
activeIndex: 0,
roundNumber: 1,
}),
);
expect(loadEncounter()).toBeNull();
});
it("returns null when activeIndex is a string instead of number", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: [{ id: "1", name: "Aria" }],
activeIndex: "zero",
roundNumber: 1,
}),
);
expect(loadEncounter()).toBeNull();
});
it("returns null when combatant entry is missing id", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: [{ name: "Aria" }],
activeIndex: 0,
roundNumber: 1,
}),
);
expect(loadEncounter()).toBeNull();
});
it("returns null when combatant entry is missing name", () => {
it("returns null when combatant has invalid required fields", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
@@ -191,88 +134,6 @@ describe("loadEncounter", () => {
expect(loadEncounter()).toBeNull();
});
it("returns null for negative roundNumber", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: [{ id: "1", name: "Aria" }],
activeIndex: 0,
roundNumber: -1,
}),
);
expect(loadEncounter()).toBeNull();
});
it("returns empty encounter for zero combatants (cleared state)", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ combatants: [], activeIndex: 0, roundNumber: 1 }),
);
const result = loadEncounter();
expect(result).toEqual({
combatants: [],
activeIndex: 0,
roundNumber: 1,
});
});
it("round-trip preserves combatant AC value", () => {
const result = createEncounter(
[{ id: combatantId("1"), name: "Aria", ac: 18 }],
0,
1,
);
if (isDomainError(result)) throw new Error("unreachable");
saveEncounter(result);
const loaded = loadEncounter();
expect(loaded?.combatants[0].ac).toBe(18);
});
it("round-trip preserves combatant without AC", () => {
const result = createEncounter(
[{ id: combatantId("1"), name: "Aria" }],
0,
1,
);
if (isDomainError(result)) throw new Error("unreachable");
saveEncounter(result);
const loaded = loadEncounter();
expect(loaded?.combatants[0].ac).toBeUndefined();
});
it("discards invalid AC values during rehydration", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: [
{ id: "1", name: "Neg", ac: -1 },
{ id: "2", name: "Float", ac: 3.5 },
{ id: "3", name: "Str", ac: "high" },
],
activeIndex: 0,
roundNumber: 1,
}),
);
const loaded = loadEncounter();
expect(loaded).not.toBeNull();
expect(loaded?.combatants[0].ac).toBeUndefined();
expect(loaded?.combatants[1].ac).toBeUndefined();
expect(loaded?.combatants[2].ac).toBeUndefined();
});
it("preserves AC of 0 during rehydration", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: [{ id: "1", name: "Aria", ac: 0 }],
activeIndex: 0,
roundNumber: 1,
}),
);
const loaded = loadEncounter();
expect(loaded?.combatants[0].ac).toBe(0);
});
it("saving after modifications persists the latest state", () => {
const encounter = makeEncounter();
saveEncounter(encounter);
@@ -90,102 +90,7 @@ describe("player-character-storage", () => {
});
});
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([]);
});
describe("delegation to domain rehydration", () => {
it("keeps valid characters and discards invalid ones", () => {
mockStorage.setItem(
STORAGE_KEY,
+21 -107
View File
@@ -1,14 +1,9 @@
import {
type ConditionId,
combatantId,
type Combatant,
createEncounter,
creatureId,
type Encounter,
isDomainError,
playerCharacterId,
VALID_CONDITION_IDS,
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
rehydrateCombatant,
} from "@initiative/domain";
const STORAGE_KEY = "initiative:encounter";
@@ -21,100 +16,7 @@ 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);
if (raw === null) return null;
const parsed: unknown = JSON.parse(raw);
export function rehydrateEncounter(parsed: unknown): Encounter | null {
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
return null;
@@ -135,18 +37,30 @@ export function loadEncounter(): Encounter | null {
};
}
if (!combatants.every(isValidCombatantEntry)) return null;
const rehydrated: Combatant[] = [];
for (const c of combatants) {
const result = rehydrateCombatant(c);
if (result === null) return null;
rehydrated.push(result);
}
const rehydrated = combatants.map(rehydrateCombatant);
const result = createEncounter(
const encounter = createEncounter(
rehydrated,
obj.activeIndex,
obj.roundNumber,
);
if (isDomainError(result)) return null;
if (isDomainError(encounter)) return null;
return result;
return encounter;
}
export function loadEncounter(): Encounter | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) return null;
const parsed: unknown = JSON.parse(raw);
return rehydrateEncounter(parsed);
} catch {
return null;
}
+118
View File
@@ -0,0 +1,118 @@
import type {
Encounter,
ExportBundle,
PlayerCharacter,
UndoRedoState,
} from "@initiative/domain";
import { rehydratePlayerCharacter } from "@initiative/domain";
import { rehydrateEncounter } from "./encounter-storage.js";
function rehydrateStack(raw: unknown[]): Encounter[] {
const result: Encounter[] = [];
for (const entry of raw) {
const rehydrated = rehydrateEncounter(entry);
if (rehydrated !== null) {
result.push(rehydrated);
}
}
return result;
}
function rehydrateCharacters(raw: unknown[]): PlayerCharacter[] {
const result: PlayerCharacter[] = [];
for (const entry of raw) {
const rehydrated = rehydratePlayerCharacter(entry);
if (rehydrated !== null) {
result.push(rehydrated);
}
}
return result;
}
export function validateImportBundle(data: unknown): ExportBundle | string {
if (typeof data !== "object" || data === null || Array.isArray(data)) {
return "Invalid file format";
}
const obj = data as Record<string, unknown>;
if (typeof obj.version !== "number" || obj.version !== 1) {
return "Invalid file format";
}
if (typeof obj.exportedAt !== "string") {
return "Invalid file format";
}
if (!Array.isArray(obj.undoStack) || !Array.isArray(obj.redoStack)) {
return "Invalid file format";
}
if (!Array.isArray(obj.playerCharacters)) {
return "Invalid file format";
}
const encounter = rehydrateEncounter(obj.encounter);
if (encounter === null) {
return "Invalid encounter data";
}
return {
version: 1,
exportedAt: obj.exportedAt,
encounter,
undoStack: rehydrateStack(obj.undoStack),
redoStack: rehydrateStack(obj.redoStack),
playerCharacters: rehydrateCharacters(obj.playerCharacters),
};
}
export function assembleExportBundle(
encounter: Encounter,
undoRedoState: UndoRedoState,
playerCharacters: readonly PlayerCharacter[],
includeHistory = true,
): ExportBundle {
return {
version: 1,
exportedAt: new Date().toISOString(),
encounter,
undoStack: includeHistory ? undoRedoState.undoStack : [],
redoStack: includeHistory ? undoRedoState.redoStack : [],
playerCharacters: [...playerCharacters],
};
}
export function bundleToJson(bundle: ExportBundle): string {
return JSON.stringify(bundle, null, 2);
}
export function resolveFilename(name?: string): string {
const base =
name?.trim() ||
`initiative-export-${new Date().toISOString().slice(0, 10)}`;
return base.endsWith(".json") ? base : `${base}.json`;
}
export function triggerDownload(bundle: ExportBundle, name?: string): void {
const blob = new Blob([bundleToJson(bundle)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const filename = resolveFilename(name);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
export async function readImportFile(
file: File,
): Promise<ExportBundle | string> {
try {
const text = await file.text();
const parsed: unknown = JSON.parse(text);
return validateImportBundle(parsed);
} catch {
return "Invalid file format";
}
}
@@ -1,9 +1,5 @@
import type { PlayerCharacter } from "@initiative/domain";
import {
playerCharacterId,
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
} from "@initiative/domain";
import { rehydratePlayerCharacter } from "@initiative/domain";
const STORAGE_KEY = "initiative:player-characters";
@@ -15,46 +11,6 @@ export function savePlayerCharacters(characters: PlayerCharacter[]): void {
}
}
function isValidOptionalMember(
value: unknown,
valid: ReadonlySet<string>,
): boolean {
return value === undefined || (typeof value === "string" && valid.has(value));
}
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 (!isValidOptionalMember(entry.color, VALID_PLAYER_COLORS)) return null;
if (!isValidOptionalMember(entry.icon, VALID_PLAYER_ICONS)) 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);
@@ -65,7 +21,7 @@ export function loadPlayerCharacters(): PlayerCharacter[] {
const characters: PlayerCharacter[] = [];
for (const item of parsed) {
const pc = rehydrateCharacter(item);
const pc = rehydratePlayerCharacter(item);
if (pc !== null) {
characters.push(pc);
}
@@ -0,0 +1,45 @@
import type { Encounter, UndoRedoState } from "@initiative/domain";
import { EMPTY_UNDO_REDO_STATE } from "@initiative/domain";
import { rehydrateEncounter } from "./encounter-storage.js";
const UNDO_KEY = "initiative:encounter:undo";
const REDO_KEY = "initiative:encounter:redo";
export function saveUndoRedoStacks(state: UndoRedoState): void {
try {
localStorage.setItem(UNDO_KEY, JSON.stringify(state.undoStack));
localStorage.setItem(REDO_KEY, JSON.stringify(state.redoStack));
} catch {
// Silently swallow errors (quota exceeded, storage unavailable)
}
}
function loadStack(key: string): readonly Encounter[] {
try {
const raw = localStorage.getItem(key);
if (raw === null) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
const valid: Encounter[] = [];
for (const entry of parsed) {
const rehydrated = rehydrateEncounter(entry);
if (rehydrated !== null) {
valid.push(rehydrated);
}
}
return valid;
} catch {
return [];
}
}
export function loadUndoRedoStacks(): UndoRedoState {
const undoStack = loadStack(UNDO_KEY);
const redoStack = loadStack(REDO_KEY);
if (undoStack.length === 0 && redoStack.length === 0) {
return EMPTY_UNDO_REDO_STATE;
}
return { undoStack, redoStack };
}
+3 -1
View File
@@ -8,7 +8,9 @@
"!.specify",
"!specs",
"!coverage",
"!.pnpm-store"
"!.pnpm-store",
"!.rodney",
"!.agent-tests"
]
},
"assist": {
+20
View File
@@ -0,0 +1,20 @@
# ADR-NNN: [Title]
**Date**: YYYY-MM-DD
**Status**: accepted | superseded | deprecated
## Context
What is the problem or situation that motivates this decision?
## Decision
What did we decide, and why?
## Alternatives Considered
What other approaches were evaluated?
## Consequences
What are the trade-offs — both positive and negative?
+45
View File
@@ -0,0 +1,45 @@
# ADR-001: Errors as Values, Not Exceptions
**Date**: 2026-03-25
**Status**: accepted
## Context
Domain functions need to communicate failure (invalid input, missing combatant, violated invariants). The standard JavaScript approach is to throw exceptions, but thrown exceptions are invisible to TypeScript's type system — nothing in a function's signature tells the caller that it can fail or what errors to expect.
This project's domain layer is designed to be pure and deterministic. Thrown exceptions break both properties: they alter control flow (a side effect) and make the function's output unpredictable from the caller's perspective.
## Decision
All domain functions return `SuccessType | DomainError` unions. `DomainError` is a plain data object with a `kind` discriminant, a machine-readable `code`, and a human-readable `message`:
```typescript
interface DomainError {
readonly kind: "domain-error";
readonly code: string;
readonly message: string;
}
```
Callers check results with the `isDomainError()` type guard before accessing success data. Errors are never thrown in the domain layer (adapter-layer code may throw for programmer errors like missing providers).
## Alternatives Considered
**Thrown exceptions** — the JavaScript default. Simpler to write (`throw new Error(...)`) but error paths are invisible to the type system. The caller has no compile-time indication that a function can fail, and `catch` blocks lose type information about which errors are possible. Would also make domain functions impure.
**Result wrapper types** (e.g., `neverthrow`, `ts-results`) — formalizes the pattern with `.map()`, `.unwrap()`, `.match()` methods. More ergonomic for chaining operations, but adds a library dependency and a layer of indirection. The project's use cases are simple enough (call domain function, check error, save or return) that raw unions are sufficient.
**Validation libraries** (Zod, io-ts) — useful for input parsing but don't cover domain logic errors like "combatant not found" or "no previous turn". Would only address a subset of the problem.
## Consequences
**Positive:**
- Error handling is compiler-enforced. Forgetting to check for an error produces a type error when accessing success fields.
- Domain functions remain pure — they return data, never alter control flow.
- Error codes are stable, machine-readable identifiers that UI code can match on.
- Testing is straightforward: assert the return value, no try/catch in tests.
**Negative:**
- Every call site must check `isDomainError()` before proceeding. This is slightly more verbose than a try/catch that wraps multiple calls.
- Composing multiple fallible operations requires manual chaining (check error, then call next function). A Result wrapper would make this more ergonomic if the codebase grows significantly.
- Contributors familiar with JavaScript conventions may initially find the pattern unfamiliar.
@@ -0,0 +1,46 @@
# ADR-002: Domain Events as Plain Data Objects
**Date**: 2026-03-25
**Status**: accepted
## Context
Domain state transitions need to communicate what happened (not just the new state) so the UI layer can react — showing toasts, auto-scrolling, opening panels, etc. The project needs an event mechanism that stays consistent with the pure, deterministic domain core.
## Decision
Domain events are plain data objects with a `type` string discriminant. They form a discriminated union (`DomainEvent`) of 18 event types. Events are returned alongside the new state from domain functions, not emitted through a pub/sub system:
```typescript
// Example event
{ type: "TurnAdvanced", previousCombatantId: "abc", newCombatantId: "def", roundNumber: 2 }
// Domain function returns both state and events
function advanceTurn(encounter: Encounter): { encounter: Encounter; events: DomainEvent[] } | DomainError
```
Events are consumed ephemerally by the UI layer and are not persisted.
## Alternatives Considered
**Class-based events** (e.g., `class TurnAdvanced extends DomainEvent { ... }`) — common in OOP-style domain-driven design. Adds inheritance hierarchies, constructors, and `instanceof` checks. No benefit here: TypeScript's discriminated union narrowing (`switch (event.type)`) provides the same exhaustiveness checking without classes. Classes also can't be serialized/deserialized without custom logic.
**Event emitter / pub-sub** (Node `EventEmitter`, custom bus, RxJS) — events are broadcast and listeners subscribe. Decouples producers from consumers, but introduces implicit coupling (who's listening?), ordering concerns, and makes the domain impure (emitting is a side effect). Harder to test — you'd need to set up listeners and collect results instead of just asserting on a return value.
**Observable streams** (RxJS) — powerful for async event processing and composition. Massive overkill for this use case: events are synchronous, produced one batch at a time, and consumed immediately. Would add a significant dependency and conceptual overhead.
**No events** (just compare old and new state) — the UI could diff states to determine what changed. Works for simple cases, but can't express intent (did HP drop because of damage or because max HP was lowered?) and gets unwieldy as the state model grows.
## Consequences
**Positive:**
- Events are serializable (JSON-compatible). If the project ever adds undo/redo or event logging, no changes to the event format are needed.
- TypeScript's `switch (event.type)` provides exhaustiveness checking — the compiler warns if a new event type is added but not handled.
- No framework coupling. Events are just data; any consumer (React, a test, a CLI) can process them identically.
- Domain functions remain pure — events are returned, not emitted.
- Testing is trivial: assert that `result.events` contains the expected objects.
**Negative:**
- Events are currently consumed and discarded. There is no event log, replay, or undo capability. The architecture supports it, but it's not built.
- Adding a new event type requires updating the `DomainEvent` union, which touches a central file. This is intentional (forces explicit acknowledgment) but adds friction.
- No built-in mechanism for event handlers to communicate back (e.g., "veto this event"). Events are informational, not transactional.
@@ -0,0 +1,53 @@
# ADR-003: Branded Types for Identity Safety
**Date**: 2026-03-25
**Status**: accepted
## Context
The domain model has multiple entity types with string-based identifiers: combatants, creatures, and player characters. All IDs are strings at runtime (UUIDs or slug-based), making it easy to accidentally pass one ID type where another is expected. Such bugs are silent — the code compiles, runs, and only fails at runtime when a lookup returns `undefined` or mutates the wrong entity.
TypeScript's structural type system treats all `string` values as interchangeable, so a plain `string` type alias provides no protection.
## Decision
Identity types use TypeScript branded types — a `string` intersected with a phantom `readonly __brand` property that exists only at the type level:
```typescript
type CombatantId = string & { readonly __brand: "CombatantId" };
type CreatureId = string & { readonly __brand: "CreatureId" };
type PlayerCharacterId = string & { readonly __brand: "PlayerCharacterId" };
```
Each type has a factory function that casts a plain string into the branded type:
```typescript
function combatantId(id: string): CombatantId {
return id as CombatantId;
}
```
The `__brand` property is never assigned at runtime — it's a compile-time-only construct. The cast in the factory is the single point where the type system is "convinced" that the string carries the brand.
## Alternatives Considered
**Plain `string` type aliases** (`type CombatantId = string`) — provides documentation value but zero type safety. TypeScript treats the alias as fully interchangeable with `string` and with all other string aliases. This is what most TypeScript codebases do, accepting the risk of ID confusion.
**Opaque types via unique symbols** (`declare const brand: unique symbol; type CombatantId = string & { [brand]: void }`) — stricter than the `__brand` approach because the symbol is truly unique and unexportable. Slightly more boilerplate and harder to read. The simpler `__brand` string approach provides sufficient safety for this codebase's scale.
**Wrapper classes** (`class CombatantId { constructor(public readonly value: string) {} }`) — provides nominal typing naturally, but introduces runtime overhead (object allocation, `.value` access everywhere), breaks JSON serialization, and doesn't play well with the project's preference for plain data over classes.
**Runtime validation** (check ID format at every function boundary) — catches errors at runtime but not at compile time. Adds overhead and doesn't prevent the bug from being written in the first place.
## Consequences
**Positive:**
- Passing a `CreatureId` where a `CombatantId` is expected produces a compile-time error — the bug is caught before the code runs.
- Zero runtime cost. The brand is erased during compilation; at runtime, IDs are plain strings.
- JSON serialization works naturally — no custom serializers needed for persistence or network transport.
- Factory functions (`combatantId()`, `creatureId()`) serve as explicit construction points, making it clear where IDs originate.
**Negative:**
- The `as CombatantId` cast in factory functions is an escape hatch from the type system. If misused (casting arbitrary strings elsewhere), the safety guarantee is undermined. In practice, casts are confined to factory functions and adapter-layer deserialization.
- The `__brand` property appears in IDE autocomplete and hover tooltips, which can be confusing for developers unfamiliar with the pattern.
- Branded types are a community convention, not a TypeScript language feature. There is no official syntax or standard library support.
@@ -0,0 +1,42 @@
# ADR-004: On-Demand Bestiary Loading via Compact Index and IndexedDB Cache
**Date**: 2026-03-25
**Status**: accepted
## Context
The application integrates a D&D creature bestiary containing 3,300+ creatures from the 5etools dataset. The full bestiary data (stat blocks, traits, actions, spellcasting) is several megabytes of JSON. Bundling it directly into the application would create two problems: a large initial download for every user, and the distribution of copyrighted game content as part of the application bundle.
## Decision
The bestiary is split into two tiers:
1. **Compact search index** (`data/bestiary/index.json`, ~350KB) — shipped with the application bundle. Contains only the fields needed for search and display in the autocomplete dropdown: name, source, AC, HP, DEX, CR, initiative proficiency, size, and type. Field names are abbreviated (`n`, `s`, `ac`, `hp`, `dx`, `cr`, `ip`, `sz`, `tp`) to minimize file size. Generated offline by `scripts/generate-bestiary-index.mjs` from a local clone of the 5etools repository.
2. **On-demand source data** — full creature stat blocks are fetched per-source when a user first needs them (e.g., when viewing a stat block or adding a creature with HP/AC pre-fill). Fetched data is cached in IndexedDB (`initiative-bestiary` database) via the `idb` library, with an in-memory Map fallback when IndexedDB is unavailable. Users can also upload source files directly or bulk-import all sources.
The application never bundles or redistributes the full creature data. Users fetch it themselves from their own configured source URLs.
## Alternatives Considered
**Bundle all bestiary data** — simplest approach, used during early development. Eliminated because it would distribute copyrighted content in the application bundle and inflate the initial download by several megabytes. Most users only need a fraction of the available sources.
**Server-side API** — a backend service could serve creature data on demand. This would keep the client lightweight and solve the bundle size concern, but the copyright issue remains — we would still be distributing copyrighted content, just from a server instead of a bundle. It also contradicts the project's local-first, single-user, no-backend architecture and would require hosting infrastructure and a network dependency for basic functionality.
**Service Worker with lazy caching** — fetch and cache bestiary data transparently via a Service Worker. More complex to implement and debug than explicit IndexedDB caching. The explicit approach gives users visibility and control over which sources are cached (via the source manager UI).
**localStorage for caching** — simpler API than IndexedDB, but localStorage has a ~5MB limit per origin, which is insufficient for multiple bestiary sources. IndexedDB has no practical storage limit.
## Consequences
**Positive:**
- The application does not distribute copyrighted game content. Users fetch data from their own sources.
- Initial bundle stays small (~350KB for the search index). The full bestiary data is only downloaded when needed and then cached locally.
- Offline capability: once sources are cached in IndexedDB, creature data is available without network access.
- Users have explicit control over cached sources (import, clear, manage via UI).
**Negative:**
- First-time use requires fetching source data before full stat blocks are available. The bulk import feature mitigates this but requires an initial download.
- The search index must be regenerated manually when the upstream 5etools dataset changes. In practice this is infrequent (new D&D source books release a few times per year), so a manual process triggered by a new book release is sufficient at this scale.
- Two separate data representations (compact index vs full source) must be kept conceptually in sync. A creature that appears in the index but whose source hasn't been fetched will show limited information until the source is cached.
- IndexedDB adds adapter complexity (async API, database versioning, migration handling) compared to the synchronous localStorage used for encounter persistence.
@@ -0,0 +1,58 @@
# ADR-005: All Quality Gates at Pre-Commit
**Date**: 2026-03-25
**Status**: accepted
## Context
This project is developed primarily through agentic coding — AI coding agents generate and modify code under human supervision. Agents are highly productive but can drift from established conventions, introduce subtle style inconsistencies, or produce code that compiles but doesn't meet the project's quality standards.
The conventional approach in most software projects is to keep pre-commit hooks lightweight (formatting, maybe linting) and defer heavier checks (tests, type checking, coverage, copy-paste detection) to CI pipelines. This optimizes for developer speed at commit time.
However, when working with AI agents, the dynamics are different. Agents iterate quickly and can fix issues immediately — but only if they receive feedback immediately. A failing CI pipeline minutes later breaks the feedback loop: the agent's context has moved on, and the human must re-engage to address the failure.
## Decision
All quality gates run at pre-commit via Lefthook, as a single sequential `pnpm check` command. No gate may exist only as a CI step or as a manual process. The full gate sequence is:
1. `pnpm audit --audit-level=high` — security vulnerability scan
2. `knip` — unused code detection
3. `biome check .` — linting and formatting (50+ rules)
4. `oxlint --tsconfig ... --type-aware` — type-aware linting
5. `check-lint-ignores.mjs` — caps biome-ignore directives
6. `check-cn-classnames.mjs` — bans template-literal classNames
7. `check-component-props.mjs` — max 8 props per component
8. `tsc --build` — TypeScript type checking
9. `vitest run` — tests with per-path coverage thresholds
10. `jscpd` — copy-paste detection
Layer boundary enforcement runs as a Vitest test within step 9.
This takes ~8 seconds on the current codebase. Every commit is guaranteed to pass all checks.
## Alternatives Considered
**Lightweight pre-commit, full checks in CI** — the industry default. Pre-commit runs only formatting and basic linting; tests, type checking, and coverage run in a CI pipeline. This is faster at commit time but creates a delayed feedback loop. For agentic coding workflows, this delay is costly: the agent produces a commit, moves on, and the CI failure arrives minutes later when context has shifted. The human must re-engage the agent with the failure context, losing the tight iteration loop.
**No pre-commit hooks, CI only** — maximum commit speed, all enforcement in CI. Risks accumulating multiple broken commits before issues surface. Particularly problematic with agents that commit frequently.
**Selective pre-commit (fast checks only)** — run formatting, linting, and type checking at pre-commit; defer tests and coverage to CI as a compromise. Still breaks the feedback loop for test failures and coverage regressions, which are the checks most likely to catch agent-introduced bugs.
**Per-change hooks (e.g., Claude Code hooks)** — run checks after every file edit or tool call, not just at commit time. This is an even tighter feedback loop than pre-commit: the agent learns about a violation seconds after introducing it, before more code is written on top of it. Claude Code supports hooks that trigger on events like `PostToolUse`, which could run linting or type checking after every file write.
However, running the full gate after every edit breaks test-driven workflows: writing a test before its implementation, or updating implementation before updating tests, produces intermediate states that legitimately fail type checking or tests. Scoping hooks to only fast, non-breaking checks (formatting, linting) would avoid this, but splits the gate into two tiers — adding complexity for unclear benefit when pre-commit already catches everything within ~8 seconds.
Pre-commit is the current sweet spot: tight enough that agents get feedback in the same context window, but not so tight that it interferes with red-green-refactor or incremental editing. Per-change hooks remain a future option if the codebase grows to a point where pre-commit becomes too slow.
## Consequences
**Positive:**
- Early backpressure in short feedback loops. Agents receive immediate, comprehensive feedback on every commit attempt. If a check fails, the agent can fix it in the same context window, maintaining continuity.
- Every commit on `main` is guaranteed to pass all quality gates. There is no state where "it compiled but the tests are broken" or "formatting drifted."
- No CI/local divergence. The same checks run everywhere, eliminating "works on my machine" or "CI caught something pre-commit didn't."
- Enforces discipline incrementally: each commit is small, clean, and complete rather than "I'll fix the tests later."
**Negative:**
- ~8 seconds per commit attempt. This is acceptable for the current codebase size but will grow with the test suite. If it exceeds ~15 seconds, selective pre-commit with CI for the rest may become necessary.
- Developers (or agents) cannot make quick "WIP" or "checkpoint" commits without passing all gates. This is intentional — every commit should be a valid state — but it prevents some workflows like committing broken code to switch branches.
- The sequential chain means a failure in step 1 (audit) prevents discovering failures in step 9 (tests). In practice, this rarely matters because failures are fixed immediately and the chain is re-run.
@@ -0,0 +1,256 @@
---
date: "2026-03-24T10:22:04.341906+00:00"
git_commit: cfd4aef724487a681e425cedfa08f3e89255f91a
branch: main
topic: "Rules edition setting for condition tooltips + settings modal"
tags: [research, codebase, conditions, settings, theme, modal, issue-12]
status: complete
---
# Research: Rules Edition Setting for Condition Tooltips + Settings Modal
## Research Question
Map the codebase for implementing issue #12: a rules edition setting (5e 2014 / 5.5e 2024) that controls condition tooltip descriptions, delivered via a new settings modal that also absorbs the existing theme toggle. Target spec: `specs/003-combatant-state/spec.md` (stories CC-3, CC-8, FR-095FR-102).
## Summary
The implementation touches five areas: (1) the domain condition definitions, (2) the tooltip rendering in two web components, (3) the kebab overflow menu in the action bar, (4) the theme system (hook + context), and (5) a new settings modal following existing `<dialog>` patterns. The localStorage persistence pattern is well-established with a consistent `"initiative:<key>"` convention. The context provider tree in `main.tsx` is the integration point for a new settings context.
## Detailed Findings
### 1. Condition Definitions and Tooltip Data Flow
**Domain layer**`packages/domain/src/conditions.ts`
The `ConditionDefinition` interface (line 18) carries a single `description: string` field. The `CONDITION_DEFINITIONS` array (line 26) holds all 15 conditions as `readonly` objects with `id`, `label`, `description`, `iconName`, and `color`. This is the single source of truth for condition data.
Exported types: `ConditionId` (union of 15 string literals), `ConditionDefinition`, `CONDITION_DEFINITIONS`, `VALID_CONDITION_IDS`.
**Web layer — condition-tags.tsx** (`apps/web/src/components/condition-tags.tsx`)
- Line 69: Looks up definition via `CONDITION_DEFINITIONS.find((d) => d.id === condId)`
- Line 75: Passes tooltip content as `` `${def.label}: ${def.description}` `` — combines label + description into a single string
- This is the tooltip shown when hovering active condition icons in the combatant row
**Web layer — condition-picker.tsx** (`apps/web/src/components/condition-picker.tsx`)
- Line 119: Iterates `CONDITION_DEFINITIONS.map(...)` directly
- Line 125: Passes `content={def.description}` to Tooltip — description only, no label prefix
- This is the tooltip shown when hovering conditions in the dropdown picker
**Key observation:** Both components read `def.description` directly from the imported domain constant. To make descriptions edition-aware, either (a) the domain type needs dual descriptions and consumers select by edition, or (b) a higher-level hook resolves the correct description before passing to components.
### 2. Tooltip Component
**File:** `apps/web/src/components/ui/tooltip.tsx`
- Props: `content: string`, `children: ReactNode`, optional `className`
- Positioning: Uses `getBoundingClientRect()` to place tooltip 4px above the trigger element, centered horizontally
- Rendered via `createPortal` to `document.body` at z-index 60
- Max width: `max-w-64` (256px / 16rem) with `text-xs leading-snug`
- Text wraps naturally within the max-width constraint — no explicit truncation
- The tooltip accepts only `string` content, not ReactNode
The current descriptions are short (1-2 sentences). The 5e (2014) exhaustion description will be longer (6-level table as text), which may benefit from the existing 256px wrapping. No changes to the tooltip component itself should be needed.
### 3. Kebab Menu (Overflow Menu)
**OverflowMenu component**`apps/web/src/components/ui/overflow-menu.tsx`
- Generic menu component accepting `items: readonly OverflowMenuItem[]`
- Each item has: `icon: ReactNode`, `label: string`, `onClick: () => void`, optional `disabled` and `keepOpen`
- Opens upward (`bottom-full`) from the kebab button, right-aligned
- Close on click-outside (mousedown) and Escape key
**ActionBar integration**`apps/web/src/components/action-bar.tsx`
- `buildOverflowItems()` function (line 231) constructs the menu items array
- Current items in order:
1. **Player Characters** (Users icon) — calls `opts.onManagePlayers`
2. **Manage Sources** (Library icon) — calls `opts.onOpenSourceManager`
3. **Import All Sources** (Import icon) — conditional on bestiary loaded
4. **Theme cycle** (Monitor/Sun/Moon icon) — calls `opts.onCycleTheme`, uses `keepOpen: true`
- Theme constants at lines 219-229: `THEME_ICONS` and `THEME_LABELS` maps
- Line 293: `useThemeContext()` provides `preference` and `cycleTheme`
- Line 529-537: Overflow items built with all options passed in
**To add a "Settings" item:** Add a new entry to `buildOverflowItems()` and remove the theme cycle entry. The new item would call a callback to open the settings modal.
### 4. Theme System
**Hook**`apps/web/src/hooks/use-theme.ts`
- Module-level state: `currentPreference` initialized from localStorage on import (line 9)
- `ThemePreference` type: `"system" | "light" | "dark"`
- `ResolvedTheme` type: `"light" | "dark"`
- Storage key: `"initiative:theme"` (line 6)
- `loadPreference()` — reads localStorage, defaults to `"system"` (lines 11-19)
- `savePreference()` — writes to localStorage, silent on error (lines 21-27)
- `resolve()` — resolves "system" via `matchMedia("(prefers-color-scheme: light)")` (lines 29-38)
- `applyTheme()` — sets `document.documentElement.dataset.theme` (lines 40-42)
- Uses `useSyncExternalStore` for React integration (line 77)
- Exposes: `preference`, `resolved`, `setPreference`, `cycleTheme`
- OS preference change listener updates theme when preference is "system" (lines 54-63)
**Context**`apps/web/src/contexts/theme-context.tsx`
- Simple wrapper: `ThemeProvider` calls `useTheme()` and provides via React context
- `useThemeContext()` hook for consumers (line 15)
**For settings modal:** The theme system already exposes `setPreference(pref)` which is exactly what the settings modal needs — direct selection instead of cycling.
### 5. localStorage Persistence Patterns
All storage follows a consistent pattern:
| Key | Content | Format |
|-----|---------|--------|
| `initiative:encounter` | Full encounter state | JSON object |
| `initiative:player-characters` | Player character array | JSON array |
| `initiative:theme` | Theme preference | Plain string |
**Common patterns:**
- Read: `try { localStorage.getItem(key) } catch { return default }`
- Write: `try { localStorage.setItem(key, value) } catch { /* silent */ }`
- Validation on read: type-check, range-check, reject invalid, return fallback
- Bootstrap: `useState(initializeFunction)` where initializer loads from storage
- Persistence: `useEffect([data], () => saveToStorage(data))`
**For rules edition:** Key would be `"initiative:rules-edition"`. Value would be a plain string (`"5e"` or `"5.5e"`), matching the theme pattern (simple string, not JSON). Default: `"5.5e"`.
### 6. Modal Patterns
Two modal implementations exist, both using HTML `<dialog>`:
**PlayerManagement** (`apps/web/src/components/player-management.tsx`)
- Controlled by `open` prop
- `useEffect` calls `dialog.showModal()` / `dialog.close()` based on `open`
- Cancel event (Escape) prevented and routed to `onClose`
- Backdrop click (mousedown on dialog element itself) routes to `onClose`
- Styling: `card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50`
- Header: title + X close button (ghost variant, muted foreground)
**CreatePlayerModal** (`apps/web/src/components/create-player-modal.tsx`)
- Same `<dialog>` pattern with identical open/close/cancel/backdrop handling
- Has form submission with validation and error display
- Same styling as PlayerManagement
**Shared dialog pattern (extract from both):**
```tsx
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
else if (!open && dialog.open) dialog.close();
}, [open]);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const handleCancel = (e: Event) => { e.preventDefault(); onClose(); };
const handleBackdropClick = (e: MouseEvent) => { if (e.target === dialog) onClose(); };
dialog.addEventListener("cancel", handleCancel);
dialog.addEventListener("mousedown", handleBackdropClick);
return () => { /* cleanup */ };
}, [onClose]);
```
### 7. Context Provider Tree
**File:** `apps/web/src/main.tsx`
Provider nesting order (outermost first):
1. `ThemeProvider`
2. `EncounterProvider`
3. `BestiaryProvider`
4. `PlayerCharactersProvider`
5. `BulkImportProvider`
6. `SidePanelProvider`
7. `InitiativeRollsProvider`
A new `SettingsProvider` (or `RulesEditionProvider`) would slot in early — before any component that reads condition descriptions. Since `ThemeProvider` is already the outermost, and the settings modal manages both theme and rules edition, one option is a `SettingsProvider` that wraps or replaces `ThemeProvider`.
### 8. 5e vs 5.5e Condition Text Differences
Based on research, here are the conditions with meaningful mechanical differences between editions. Conditions not listed are functionally identical across editions.
**Major changes:**
| Condition | 5e (2014) | 5.5e (2024) — current text |
|---|---|---|
| **Exhaustion** | 6 escalating levels: L1 disadvantage on ability checks, L2 speed halved, L3 disadvantage on attacks/saves, L4 HP max halved, L5 speed 0, L6 death | level from d20 tests and spell save DCs. Speed reduced by 5 ft. × level. Death at 10 levels. (current) |
| **Grappled** | Speed 0. Ends if grappler incapacitated or moved out of reach. | Speed 0, can't benefit from speed bonuses. Ends if grappler incapacitated or moved out of reach. (current — but 2024 also adds disadvantage on attacks vs non-grappler) |
| **Invisible** | Can't be seen without magic/special sense. Heavily obscured. Advantage on attacks; disadvantage on attacks against. | 2024 broadened: can be gained from Hide action; grants Surprise (advantage on initiative), Concealed (unaffected by sight effects), attacks advantage/disadvantage. (current text is closer to 2014) |
| **Stunned** | Incapacitated. Can't move. Speak falteringly. Auto-fail Str/Dex saves. Attacks against have advantage. | 2024: same but can still move (controversial). (current text says "Can't move" — matches 2014) |
**Moderate changes:**
| Condition | 5e (2014) | 5.5e (2024) |
|---|---|---|
| **Incapacitated** | Can't take actions or reactions. | Can't take actions, bonus actions, or reactions. Speed 0. Auto-fail Str/Dex saves. Attacks against have advantage. Concentration broken. (current is partial 2024) |
| **Petrified** | Unaware of surroundings. | Aware of surroundings (2024 change). Current text doesn't mention awareness. |
| **Poisoned** | Disadvantage on attacks and ability checks. | Same, but 2024 consolidates disease into poisoned. |
**Minor/identical:**
Blinded, Charmed ("harmful" → "damaging"), Deafened, Frightened, Paralyzed, Prone, Restrained, Unconscious — functionally identical between editions.
**Note on current descriptions:** The existing `conditions.ts` descriptions are a mix — exhaustion is clearly 2024, but stunned says "Can't move" which matches 2014. A full audit of each description against both editions will be needed during implementation to ensure accuracy.
## Code References
- `packages/domain/src/conditions.ts:18-24``ConditionDefinition` interface (single `description` field)
- `packages/domain/src/conditions.ts:26-145``CONDITION_DEFINITIONS` array with current (mixed edition) descriptions
- `apps/web/src/components/condition-tags.tsx:75` — Tooltip with `${def.label}: ${def.description}`
- `apps/web/src/components/condition-picker.tsx:125` — Tooltip with `def.description`
- `apps/web/src/components/ui/tooltip.tsx:1-55` — Tooltip component (string content, 256px max-width)
- `apps/web/src/components/ui/overflow-menu.tsx:1-73` — Generic overflow menu
- `apps/web/src/components/action-bar.tsx:231-274``buildOverflowItems()` (current menu items)
- `apps/web/src/components/action-bar.tsx:293``useThemeContext()` usage in ActionBar
- `apps/web/src/hooks/use-theme.ts:1-98` — Theme hook with localStorage, `useSyncExternalStore`, cycle/set
- `apps/web/src/contexts/theme-context.tsx:1-19` — Theme context provider
- `apps/web/src/main.tsx:17-35` — Provider nesting order
- `apps/web/src/components/player-management.tsx:55-131``<dialog>` modal pattern (reference for settings modal)
- `apps/web/src/components/create-player-modal.tsx:106-191` — Form-based `<dialog>` modal pattern
- `apps/web/src/persistence/encounter-storage.ts` — localStorage persistence pattern (read/write/validate)
- `apps/web/src/persistence/player-character-storage.ts` — localStorage persistence pattern
## Architecture Documentation
### Data Flow: Condition Description → Tooltip
```
Domain: CONDITION_DEFINITIONS[].description (single string)
↓ imported by
Web: condition-tags.tsx → Tooltip content={`${label}: ${description}`}
Web: condition-picker.tsx → Tooltip content={description}
↓ rendered by
UI: tooltip.tsx → createPortal → fixed-position div (max-w-64)
```
### Settings/Preference Architecture
```
localStorage → use-theme.ts (useSyncExternalStore) → theme-context.tsx → consumers
localStorage → encounter-storage.ts → use-encounter.ts (useState) → encounter-context.tsx
localStorage → player-character-storage.ts → use-player-characters.ts (useState) → pc-context.tsx
```
### Modal Triggering Pattern
```
ActionBar overflow menu item click
→ callback prop (e.g., onManagePlayers)
→ App.tsx calls imperative handle (e.g., playerCharacterRef.current.openManagement())
→ Section component sets open state
→ <dialog>.showModal() via useEffect
```
## Open Questions
1. **Current description accuracy:** The existing descriptions are a mix of 2014 and 2024 text (e.g., exhaustion is 2024, stunned "Can't move" is 2014). Both sets of descriptions need careful authoring against official sources during implementation.
2. **Domain type change:** Should `ConditionDefinition` carry `description5e` and `description55e` fields, or should description resolution happen at the application/web layer? The domain-level approach is simpler and keeps the data co-located with condition definitions.
3. **Settings context scope:** Should a new `SettingsProvider` manage both rules edition and theme, or should rules edition be its own context? The theme system already has its own well-structured hook/context; combining them may add unnecessary coupling.
@@ -0,0 +1,238 @@
---
date: 2026-03-28T01:35:07.925247+00:00
git_commit: f4fb69dbc763fefe4a73b3491c27093bbd06af0d
branch: main
topic: "Entity rehydration: current implementation and migration surface"
tags: [research, codebase, rehydration, persistence, domain, player-character, combatant]
status: complete
---
# Research: Entity Rehydration — Current Implementation and Migration Surface
## Research Question
Map all entity rehydration logic (reconstructing typed domain objects from untyped JSON) across the codebase. Document what validation each rehydration function performs, where it lives, how functions cross-reference each other, and what the domain layer already provides that could replace adapter-level validation. This research supports Issue #20: Move entity rehydration to domain layer.
## Summary
Entity rehydration currently lives entirely in `apps/web/src/persistence/`. Two primary rehydration functions exist:
1. **`rehydrateCharacter`** in `player-character-storage.ts` — validates and reconstructs `PlayerCharacter` from unknown JSON
2. **`rehydrateCombatant`** (private) + **`rehydrateEncounter`** (exported) in `encounter-storage.ts` — validates and reconstructs `Combatant`/`Encounter` from unknown JSON
These are consumed by three call sites: localStorage loading, undo/redo stack loading, and JSON import validation. The domain layer already contains parallel validation logic in `createPlayerCharacter`, `addCombatant`/`validateInit`, and `createEncounter`, but the rehydration functions duplicate this validation with subtly different rules (rehydration is lenient/recovering; creation is strict/rejecting).
## Detailed Findings
### 1. PlayerCharacter Rehydration
**File**: `apps/web/src/persistence/player-character-storage.ts:25-65`
`rehydrateCharacter(raw: unknown): PlayerCharacter | null` performs:
| Field | Validation | Behavior on invalid |
|-------|-----------|-------------------|
| `id` | `typeof string`, non-empty | Return `null` (reject entire PC) |
| `name` | `typeof string`, non-empty after trim | Return `null` |
| `ac` | `typeof number`, integer, `>= 0` | Return `null` |
| `maxHp` | `typeof number`, integer, `>= 1` | Return `null` |
| `color` | Optional; if present, must be in `VALID_PLAYER_COLORS` | Return `null` |
| `icon` | Optional; if present, must be in `VALID_PLAYER_ICONS` | Return `null` |
| `level` | Optional; if present, must be integer 1-20 | Return `null` |
Constructs result via branded `playerCharacterId()` and type assertions for color/icon.
**Helper**: `isValidOptionalMember(value, validSet)` — shared check for optional set-membership fields (lines 18-23).
**Callers**:
- `loadPlayerCharacters()` (same file, line 67) — loads from localStorage
- `rehydrateCharacters()` in `export-import.ts:21-30` — filters PCs during import validation
### 2. Combatant Rehydration
**File**: `apps/web/src/persistence/encounter-storage.ts:67-103`
`rehydrateCombatant(c: unknown)` (private, no return type annotation) performs:
| Field | Validation | Behavior on invalid |
|-------|-----------|-------------------|
| `id` | Cast directly (`entry.id as string`) | No validation (relies on `isValidCombatantEntry` pre-check) |
| `name` | Cast directly (`entry.name as string`) | No validation (relies on pre-check) |
| `initiative` | `typeof number` or `undefined` | Defaults to `undefined` |
| `ac` | Via `validateAc`: integer `>= 0` | Defaults to `undefined` |
| `conditions` | Via `validateConditions`: array, each in `VALID_CONDITION_IDS` | Defaults to `undefined` |
| `isConcentrating` | Strictly `=== true` | Defaults to `undefined` |
| `creatureId` | Via `validateCreatureId`: non-empty string | Defaults to `undefined` |
| `color` | String in `VALID_PLAYER_COLORS` | Defaults to `undefined` |
| `icon` | String in `VALID_PLAYER_ICONS` | Defaults to `undefined` |
| `playerCharacterId` | Non-empty string | Defaults to `undefined` |
| `maxHp` / `currentHp` | Via `validateHp`: maxHp integer >= 1, currentHp integer 0..maxHp | Defaults to `undefined`; invalid currentHp falls back to maxHp |
**Key difference from PC rehydration**: Combatant rehydration is *lenient* — invalid optional fields are silently dropped rather than rejecting the entire entity. Only `id` and `name` are required (checked by `isValidCombatantEntry` at line 105-109 before `rehydrateCombatant` is called).
**Helper functions** (all private):
- `validateAc(value)` — lines 24-28
- `validateConditions(value)` — lines 30-37
- `validateCreatureId(value)` — lines 39-43
- `validateHp(rawMaxHp, rawCurrentHp)` — lines 45-65
### 3. Encounter Rehydration
**File**: `apps/web/src/persistence/encounter-storage.ts:111-140`
`rehydrateEncounter(parsed: unknown): Encounter | null` validates the encounter envelope:
- Must be a non-null, non-array object
- `combatants` must be an array
- `activeIndex` must be a number
- `roundNumber` must be a number
- Empty combatant array → returns hardcoded `{ combatants: [], activeIndex: 0, roundNumber: 1 }`
- All entries must pass `isValidCombatantEntry` (id + name check)
- Maps entries through `rehydrateCombatant`, then passes to domain's `createEncounter` for invariant enforcement
**Callers**:
- `loadEncounter()` (same file, line 142) — localStorage
- `loadStack()` in `undo-redo-storage.ts:17-36` — undo/redo stacks from localStorage
- `rehydrateStack()` in `export-import.ts:10-19` — import validation
- `validateImportBundle()` in `export-import.ts:32-65` — import validation (direct call for the main encounter)
### 4. Import Bundle Validation
**File**: `apps/web/src/persistence/export-import.ts:32-65`
`validateImportBundle(data: unknown): ExportBundle | string` validates the bundle envelope:
- Version must be `1`
- `exportedAt` must be a string
- `undoStack` and `redoStack` must be arrays
- `playerCharacters` must be an array
- Delegates to `rehydrateEncounter` for the encounter
- Delegates to `rehydrateStack` (which calls `rehydrateEncounter`) for undo/redo
- Delegates to `rehydrateCharacters` (which calls `rehydrateCharacter`) for PCs
This function validates the *envelope* structure. Entity-level validation is fully delegated.
### 5. Domain Layer Validation (Existing)
The domain already contains validation for the same fields, but in *creation* context (typed inputs, DomainError returns):
**`createPlayerCharacter`** (`packages/domain/src/create-player-character.ts:17-100`):
- Same field rules as `rehydrateCharacter`: name non-empty, ac >= 0 integer, maxHp >= 1 integer, color/icon in valid sets, level 1-20
- Returns `DomainError` on invalid input (not `null`)
**`validateInit`** in `addCombatant` (`packages/domain/src/add-combatant.ts:27-53`):
- Validates maxHp (positive integer), ac (non-negative integer), initiative (integer)
- Does NOT validate conditions, color, icon, playerCharacterId, creatureId, isConcentrating
**`createEncounter`** (`packages/domain/src/types.ts:50-71`):
- Validates activeIndex bounds and roundNumber (positive integer)
- Already used by `rehydrateEncounter` as the final step
**`editPlayerCharacter`** (`packages/domain/src/edit-player-character.ts`):
- `validateFields` validates the same PC fields for edits
### 6. Validation Overlap and Gaps
| Field | Rehydration validates | Domain validates |
|-------|----------------------|-----------------|
| PC.id | Non-empty string | N/A (caller provides) |
| PC.name | Non-empty string | Non-empty (trimmed) |
| PC.ac | Integer >= 0 | Integer >= 0 |
| PC.maxHp | Integer >= 1 | Integer >= 1 |
| PC.color | In VALID_PLAYER_COLORS | In VALID_PLAYER_COLORS |
| PC.icon | In VALID_PLAYER_ICONS | In VALID_PLAYER_ICONS |
| PC.level | Integer 1-20 | Integer 1-20 |
| Combatant.id | Non-empty string (via pre-check) | N/A (caller provides) |
| Combatant.name | String type (via pre-check) | Non-empty (trimmed) |
| Combatant.initiative | `typeof number` | Integer |
| Combatant.ac | Integer >= 0 | Integer >= 0 |
| Combatant.maxHp | Integer >= 1 | Integer >= 1 |
| Combatant.currentHp | Integer 0..maxHp | N/A (set = maxHp on add) |
| Combatant.tempHp | **Not validated** | N/A |
| Combatant.conditions | Each in VALID_CONDITION_IDS | N/A (toggleCondition checks) |
| Combatant.isConcentrating | Strictly `true` or dropped | N/A (toggleConcentration) |
| Combatant.creatureId | Non-empty string | N/A (passed through) |
| Combatant.color | In VALID_PLAYER_COLORS | N/A (passed through) |
| Combatant.icon | In VALID_PLAYER_ICONS | N/A (passed through) |
| Combatant.playerCharacterId | Non-empty string | N/A (passed through) |
Key observations:
- Rehydration validates `id` (required for identity); domain creation functions receive `id` as a typed parameter
- Combatant rehydration does NOT validate `tempHp` at all — it's silently passed through or ignored
- Combatant rehydration checks `initiative` as `typeof number` but domain checks `Number.isInteger` — slightly different strictness
- Domain validation for combatant optional fields is scattered across individual mutation functions, not centralized
### 7. Test Coverage
**Persistence tests** (adapter layer):
- `encounter-storage.test.ts` — ~27 tests covering round-trip, corrupt data, AC validation, edge cases
- `player-character-storage.test.ts` — ~17 tests covering round-trip, corrupt data, field validation, level
**Import tests** (adapter layer):
- `validate-import-bundle.test.ts` — ~21 tests covering envelope validation, graceful recovery, PC filtering
- `export-import.test.ts` — ~15 tests covering bundle assembly, round-trip, filename resolution
**Domain tests**: No rehydration tests exist in `packages/domain/` — all rehydration testing is in the adapter layer.
### 8. Cross-Reference Map
```
loadPlayerCharacters() ──→ rehydrateCharacter()
validateImportBundle() ──→ rehydrateCharacters() ──┘
├─→ rehydrateEncounter() ──→ isValidCombatantEntry()
│ ├─→ rehydrateCombatant() ──→ validateAc()
│ │ ├─→ validateConditions()
│ │ ├─→ validateCreatureId()
│ │ └─→ validateHp()
│ └─→ createEncounter() [domain]
└─→ rehydrateStack() ───→ rehydrateEncounter() [same as above]
loadEncounter() ───────→ rehydrateEncounter() [same as above]
loadUndoRedoStacks() ──→ loadStack() ──→ rehydrateEncounter() [same as above]
```
## Code References
- `apps/web/src/persistence/player-character-storage.ts:25-65``rehydrateCharacter` (PC rehydration)
- `apps/web/src/persistence/player-character-storage.ts:18-23``isValidOptionalMember` helper
- `apps/web/src/persistence/encounter-storage.ts:24-28``validateAc` helper
- `apps/web/src/persistence/encounter-storage.ts:30-37``validateConditions` helper
- `apps/web/src/persistence/encounter-storage.ts:39-43``validateCreatureId` helper
- `apps/web/src/persistence/encounter-storage.ts:45-65``validateHp` helper
- `apps/web/src/persistence/encounter-storage.ts:67-103``rehydrateCombatant` (combatant rehydration)
- `apps/web/src/persistence/encounter-storage.ts:105-109``isValidCombatantEntry` (pre-check)
- `apps/web/src/persistence/encounter-storage.ts:111-140``rehydrateEncounter` (encounter envelope rehydration)
- `apps/web/src/persistence/export-import.ts:10-30``rehydrateStack` / `rehydrateCharacters` (collection wrappers)
- `apps/web/src/persistence/export-import.ts:32-65``validateImportBundle` (import envelope validation)
- `apps/web/src/persistence/undo-redo-storage.ts:17-36``loadStack` (undo/redo rehydration)
- `packages/domain/src/create-player-character.ts:17-100` — PC creation validation
- `packages/domain/src/add-combatant.ts:27-53``validateInit` (combatant creation validation)
- `packages/domain/src/types.ts:50-71``createEncounter` (encounter invariant enforcement)
- `packages/domain/src/types.ts:12-26``Combatant` type definition
- `packages/domain/src/player-character-types.ts:70-83``PlayerCharacter` type definition
## Architecture Documentation
### Current pattern
Rehydration is an adapter concern — persistence adapters validate raw JSON and construct typed domain objects. The domain provides creation functions that validate typed inputs for new entities, but no functions for reconstructing entities from untyped serialized data.
### Rehydration vs. creation semantics
Rehydration and creation serve different purposes:
- **Creation** (domain): Validates business rules for *new* entities. Receives typed parameters. Returns `DomainError` on failure.
- **Rehydration** (adapter): Reconstructs *previously valid* entities from serialized JSON. Receives `unknown`. Returns `null` on failure. May be lenient (combatants drop invalid optional fields) or strict (PCs reject on any invalid field).
### Delegation chain
`rehydrateEncounter` already delegates to `createEncounter` for encounter-level invariants. The entity-level rehydration functions (`rehydrateCharacter`, `rehydrateCombatant`) do NOT delegate to any domain function — they re-implement field validation inline.
### tempHp gap
`Combatant.tempHp` is defined in the domain type but has no validation in the current rehydration code. It appears to be silently included or excluded depending on what `rehydrateCombatant` constructs (it's not in the explicit field list, so it would be dropped during rehydration).
## Open Questions
1. **Should `rehydrateCombatant` remain lenient (drop invalid optional fields) or become strict like `rehydrateCharacter` (reject on any invalid field)?** The current asymmetry is intentional: combatants can exist with minimal data (just id + name), while PCs always require ac/maxHp.
2. **Should `tempHp` be validated during rehydration?** It's currently missing from combatant rehydration but is a valid field on the type.
3. **Should `rehydrateEncounter` move to domain too, or only the entity-level functions?** The issue acceptance criteria says "validateImportBundle and rehydrateEncounter are unchanged" — but `rehydrateEncounter` currently lives alongside `rehydrateCombatant` and would need to import from domain instead of calling the local function.
4. **Should `isValidCombatantEntry` (the pre-check) be part of the domain rehydration or remain in the adapter?** It's currently the gate that ensures `id` and `name` exist before `rehydrateCombatant` is called.
+27 -2
View File
@@ -1,4 +1,29 @@
pre-commit:
parallel: true
jobs:
- name: check
run: pnpm check
- name: audit
run: pnpm audit --audit-level=high
- name: knip
run: pnpm exec knip
- name: biome
run: pnpm exec biome check .
- name: check-ignores
run: node scripts/check-lint-ignores.mjs
- name: check-classnames
run: node scripts/check-cn-classnames.mjs
- name: check-props
run: node scripts/check-component-props.mjs
- name: jscpd
run: pnpm exec jscpd
- name: jsinspect
run: pnpm jsinspect
- name: typecheck-oxlint-test
group:
piped: true
jobs:
- name: typecheck
run: pnpm exec tsc --build
- name: oxlint
run: pnpm oxlint
- name: test
run: pnpm test
+5 -2
View File
@@ -3,13 +3,15 @@
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
"pnpm": {
"overrides": {
"undici": ">=7.24.0"
"undici": ">=7.24.0",
"picomatch": ">=4.0.4"
}
},
"devDependencies": {
"@biomejs/biome": "2.4.8",
"@vitest/coverage-v8": "^4.1.0",
"jscpd": "^4.0.8",
"jsinspect-plus": "^3.1.3",
"knip": "^5.88.1",
"lefthook": "^2.1.4",
"oxlint": "^1.56.0",
@@ -28,10 +30,11 @@
"test:watch": "vitest",
"knip": "knip",
"jscpd": "jscpd",
"jsinspect": "jsinspect -c .jsinspectrc apps/web/src packages/domain/src packages/application/src",
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
"check:ignores": "node scripts/check-lint-ignores.mjs",
"check:classnames": "node scripts/check-cn-classnames.mjs",
"check:props": "node scripts/check-component-props.mjs",
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && node scripts/check-component-props.mjs && tsc --build && vitest run && jscpd"
"check": "pnpm audit --audit-level=high && knip && biome check . && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && node scripts/check-component-props.mjs && jscpd && pnpm jsinspect && tsc --build && oxlint --tsconfig apps/web/tsconfig.json --type-aware && vitest run"
}
}
+25 -3
View File
@@ -1,6 +1,14 @@
import type { Encounter, PlayerCharacter } from "@initiative/domain";
import { isDomainError } from "@initiative/domain";
import type { EncounterStore, PlayerCharacterStore } from "../ports.js";
import type {
Encounter,
PlayerCharacter,
UndoRedoState,
} from "@initiative/domain";
import { EMPTY_UNDO_REDO_STATE, isDomainError } from "@initiative/domain";
import type {
EncounterStore,
PlayerCharacterStore,
UndoRedoStore,
} from "../ports.js";
export function requireSaved<T>(value: T | null): T {
if (value === null) throw new Error("Expected store.saved to be non-null");
@@ -52,3 +60,17 @@ export function stubPlayerCharacterStore(
};
return stub;
}
export function stubUndoRedoStore(
initial: UndoRedoState = EMPTY_UNDO_REDO_STATE,
): UndoRedoStore & { saved: UndoRedoState | null } {
const stub = {
saved: null as UndoRedoState | null,
get: () => initial,
save: (state: UndoRedoState) => {
stub.saved = state;
stub.get = () => state;
},
};
return stub;
}
@@ -2,8 +2,10 @@ import {
type ConditionId,
combatantId,
createEncounter,
EMPTY_UNDO_REDO_STATE,
isDomainError,
playerCharacterId,
pushUndo,
} from "@initiative/domain";
import { describe, expect, it } from "vitest";
import { addCombatantUseCase } from "../add-combatant-use-case.js";
@@ -14,17 +16,21 @@ import { createPlayerCharacterUseCase } from "../create-player-character-use-cas
import { deletePlayerCharacterUseCase } from "../delete-player-character-use-case.js";
import { editCombatantUseCase } from "../edit-combatant-use-case.js";
import { editPlayerCharacterUseCase } from "../edit-player-character-use-case.js";
import { redoUseCase } from "../redo-use-case.js";
import { removeCombatantUseCase } from "../remove-combatant-use-case.js";
import { retreatTurnUseCase } from "../retreat-turn-use-case.js";
import { setAcUseCase } from "../set-ac-use-case.js";
import { setHpUseCase } from "../set-hp-use-case.js";
import { setInitiativeUseCase } from "../set-initiative-use-case.js";
import { setTempHpUseCase } from "../set-temp-hp-use-case.js";
import { toggleConcentrationUseCase } from "../toggle-concentration-use-case.js";
import { toggleConditionUseCase } from "../toggle-condition-use-case.js";
import { undoUseCase } from "../undo-use-case.js";
import {
requireSaved,
stubEncounterStore,
stubPlayerCharacterStore,
stubUndoRedoStore,
} from "./helpers.js";
const ID_A = combatantId("a");
@@ -386,3 +392,80 @@ describe("editPlayerCharacterUseCase", () => {
expect(store.saved).toBeNull();
});
});
describe("setTempHpUseCase", () => {
it("sets temp HP and saves", () => {
const enc = encounterWithHp("Goblin", 10);
const store = stubEncounterStore(enc);
const result = setTempHpUseCase(store, combatantId("Goblin"), 5);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].tempHp).toBe(5);
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = setTempHpUseCase(store, ID_A, 5);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("undoUseCase", () => {
it("restores previous encounter and saves both stores", () => {
const previous = encounterWith("A");
const current = encounterWith("A", "B");
const undoRedoState = pushUndo(EMPTY_UNDO_REDO_STATE, previous);
const encounterStore = stubEncounterStore(current);
const undoRedoStore = stubUndoRedoStore(undoRedoState);
const result = undoUseCase(encounterStore, undoRedoStore);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(encounterStore.saved).combatants).toHaveLength(1);
expect(undoRedoStore.saved).not.toBeNull();
});
it("returns domain error when nothing to undo", () => {
const encounterStore = stubEncounterStore(emptyEncounter());
const undoRedoStore = stubUndoRedoStore();
const result = undoUseCase(encounterStore, undoRedoStore);
expect(isDomainError(result)).toBe(true);
expect(encounterStore.saved).toBeNull();
expect(undoRedoStore.saved).toBeNull();
});
});
describe("redoUseCase", () => {
it("restores next encounter and saves both stores", () => {
const previous = encounterWith("A");
const current = encounterWith("A", "B");
// Simulate: undo pushed current to redoStack
const undoRedoState = {
undoStack: [],
redoStack: [current],
};
const encounterStore = stubEncounterStore(previous);
const undoRedoStore = stubUndoRedoStore(undoRedoState);
const result = redoUseCase(encounterStore, undoRedoStore);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(encounterStore.saved).combatants).toHaveLength(2);
expect(undoRedoStore.saved).not.toBeNull();
});
it("returns domain error when nothing to redo", () => {
const encounterStore = stubEncounterStore(emptyEncounter());
const undoRedoStore = stubUndoRedoStore();
const result = redoUseCase(encounterStore, undoRedoStore);
expect(isDomainError(result)).toBe(true);
expect(encounterStore.saved).toBeNull();
expect(undoRedoStore.saved).toBeNull();
});
});
@@ -1,24 +1,20 @@
import {
addCombatant,
type CombatantId,
type CombatantInit,
type DomainError,
type DomainEvent,
isDomainError,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function addCombatantUseCase(
store: EncounterStore,
id: CombatantId,
name: string,
init?: CombatantInit,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = addCombatant(encounter, id, name);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
return runEncounterAction(store, (encounter) =>
addCombatant(encounter, id, name, init),
);
}
+4 -10
View File
@@ -3,22 +3,16 @@ import {
type CombatantId,
type DomainError,
type DomainEvent,
isDomainError,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function adjustHpUseCase(
store: EncounterStore,
combatantId: CombatantId,
delta: number,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = adjustHp(encounter, combatantId, delta);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
return runEncounterAction(store, (encounter) =>
adjustHp(encounter, combatantId, delta),
);
}
@@ -2,20 +2,12 @@ import {
advanceTurn,
type DomainError,
type DomainEvent,
isDomainError,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function advanceTurnUseCase(
store: EncounterStore,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = advanceTurn(encounter);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
return runEncounterAction(store, (encounter) => advanceTurn(encounter));
}
@@ -2,20 +2,12 @@ import {
clearEncounter,
type DomainError,
type DomainEvent,
isDomainError,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function clearEncounterUseCase(
store: EncounterStore,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = clearEncounter(encounter);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
return runEncounterAction(store, (encounter) => clearEncounter(encounter));
}
@@ -15,6 +15,7 @@ export function createPlayerCharacterUseCase(
maxHp: number,
color: string | undefined,
icon: string | undefined,
level?: number,
): DomainEvent[] | DomainError {
const characters = store.getAll();
const result = createPlayerCharacter(
@@ -25,6 +26,7 @@ export function createPlayerCharacterUseCase(
maxHp,
color,
icon,
level,
);
if (isDomainError(result)) {
@@ -3,22 +3,16 @@ import {
type DomainError,
type DomainEvent,
editCombatant,
isDomainError,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function editCombatantUseCase(
store: EncounterStore,
id: CombatantId,
newName: string,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = editCombatant(encounter, id, newName);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
return runEncounterAction(store, (encounter) =>
editCombatant(encounter, id, newName),
);
}
@@ -13,6 +13,7 @@ interface EditFields {
readonly maxHp?: number;
readonly color?: string | null;
readonly icon?: string | null;
readonly level?: number | null;
}
export function editPlayerCharacterUseCase(

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