106 Commits

Author SHA1 Message Date
Lukas
9437272fe0 Batch bestiary add produces a single undo entry
All checks were successful
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
All checks were successful
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
All checks were successful
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
All checks were successful
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
All checks were successful
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
All checks were successful
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
All checks were successful
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
All checks were successful
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)
All checks were successful
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
Lukas
968cc7239b Downgrade Knip 6 to 5 for CI compatibility
All checks were successful
CI / check (push) Successful in 1m6s
CI / build-image (push) Successful in 22s
Knip 6 uses oxc-parser which attempts a 6GB ArrayBuffer allocation
that fails on the CI runner (3.7GB RAM, no swap). This is a known
oxc allocator issue (oxc-project/oxc#20513) with no fix yet.
Revert to Knip 5 which uses TypeScript's parser. Also revert the
NODE_OPTIONS workaround since it's no longer needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:31:58 +01:00
Lukas
d9562f850c Inline NODE_OPTIONS for CI check step
Some checks failed
CI / check (push) Failing after 18s
CI / build-image (push) Has been skipped
Step-level env may not propagate to pnpm subprocesses in Gitea
Actions. Inline the variable directly in the command instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:48:33 +01:00
Lukas
ec9f2e7877 Increase Node heap limit for CI check step
Some checks failed
CI / check (push) Failing after 16s
CI / build-image (push) Has been skipped
oxc-parser (used by Knip) fails with ArrayBuffer allocation
error on the CI runner's default heap size. Set max-old-space-size
to 2048MB to accommodate the buffer allocation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:40:33 +01:00
Lukas
c4079c384b Fix initiative input clipping inside container
Some checks failed
CI / check (push) Failing after 17s
CI / build-image (push) Has been skipped
Widen initiative grid column from 3rem to 3.5rem and use w-full
on the editing input so it fits within the rounded background
container without overflowing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:26:39 +01:00
Lukas
a4285fc415 Polish stat containers and optical alignment
Refine AC shield to use filled shape with border color instead of
stroke outline. Add subtle muted background to initiative container.
Apply optical vertical centering to round badge text (-3px) and
AC shield number (-2px). Unify round badge corners to rounded-md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:23:22 +01:00
Lukas
9c0e3398f1 Move AC shield next to initiative and refine shield style
Place AC between initiative and name to group static reference
stats on the left, leaving HP as the sole dynamic element on
the right. Dim the shield outline to 40% opacity so it recedes
visually, and nudge the number up 2px toward the visual center.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:54:20 +01:00
Lukas
9cdf004c15 Restyle HP display as compact rounded pill
Group current HP, temp HP, and max HP into a single bordered
pill container with a subtle slash separator. Removes the
scattered layout with separate elements and gaps. Temp HP +N
only renders when present (no invisible spacer).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:11:28 +01:00
Lukas
8bf69fd47d Add temporary hit points as a separate damage buffer
Temp HP absorbs damage before current HP, cannot be healed, and
does not stack (higher value wins). Displayed as cyan +N after
current HP with a Shield button in the HP adjustment popover.
Column space is reserved across all rows only when any combatant
has temp HP. Concentration pulse fires on any damage, including
damage fully absorbed by temp HP.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 11:39:47 +01:00
Lukas
7b83e3c3ea Upgrade pnpm 10.6.0 to 10.32.1
Fixes Node DEP0169 url.parse() deprecation warning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:17:07 +01:00
Lukas
c3c2cad798 Upgrade lefthook 1 to 2
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:15:23 +01:00
Lukas
3f6140303d Upgrade knip 5 to 6
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:09:56 +01:00
Lukas
fd30278474 Upgrade jsdom 28 to 29
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:09:11 +01:00
Lukas
278c06221f Upgrade Vite 8, plugin-react 6, Vitest 4
Vite 6→8 (Rolldown/Oxc), @vitejs/plugin-react 4→6 (Babel-free), Vitest 3→4 (AST coverage)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:41:14 +01:00
Lukas
722e8cc627 Update patch/minor dev dependencies
Biome 2.4.7→2.4.8, Tailwind 4.2.1→4.2.2, oxlint 1.55→1.56, oxlint-tsgolint 0.16→0.17.1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:17:26 +01:00
Lukas
64741956dd Preserve search input and focus when toggling browse mode
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:25:40 +01:00
Lukas
6336dec38a Add condition tooltips with 5.5e descriptions
All checks were successful
CI / check (push) Successful in 1m22s
CI / build-image (push) Successful in 19s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:48:23 +01:00
Lukas
9def2d7c24 Fix condition picker clipping out of viewport
All checks were successful
CI / check (push) Successful in 1m17s
CI / build-image (push) Successful in 27s
Render condition picker via React portal with fixed positioning so it
is no longer clipped by the overflow-y-auto combatant list container.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:34:15 +01:00
Lukas
f729e37689 Replace book icon with name-click stat block toggle and pencil rename
Name click now opens/collapses the stat block panel; a hover-visible
pencil icon next to the name handles renaming. Removes the standalone
book icon for a cleaner, more intuitive combatant row.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:29:56 +01:00
Lukas
86768842ff Refactor App.tsx from god component to context-based architecture
All checks were successful
CI / check (push) Successful in 1m18s
CI / build-image (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 15:33:33 +01:00
Lukas
6584d8d064 Add advantage/disadvantage rolling for initiative
All checks were successful
CI / check (push) Successful in 1m17s
CI / build-image (push) Successful in 19s
Right-click or long-press the d20 button (per-combatant or Roll All)
to open a context menu with Advantage and Disadvantage options.
Normal left-click behavior is unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:16:04 +01:00
Lukas
7f38cbab73 Preserve stat block panel collapsed state on turn advance
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 23:23:52 +01:00
Lukas
2971898f0c Add dark and light theme with OS preference support
All checks were successful
CI / check (push) Successful in 1m22s
CI / build-image (push) Successful in 36s
Follow OS color scheme by default, with a three-way toggle
(System / Light / Dark) in the kebab menu. Light theme uses warm,
neutral tones with soft card-to-background contrast. Semantic colors
(damage, healing, conditions) keep their hue across themes.

Closes #10

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 13:24:18 +01:00
Lukas
43780772f6 Improve bestiary icon UX and auto-update stat block on turn change
- Use Book/BookOpen icons to indicate stat block open state
- Bump combatant icons (bestiary + PC) from 14px to 16px
- Use text-foreground for bestiary icon visibility
- Auto-update stat block panel to active combatant's creature on turn advance
- Update bestiary spec edge case to reflect new behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 12:48:38 +01:00
Lukas
7b3dbe2069 Use ghost buttons for turn navigation to blend with top bar
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 12:18:31 +01:00
Lukas
827a3978e9 Show toast and open source panel when rolling initiative without loaded source
When a user clicks the d20 to roll initiative for a single combatant whose
bestiary source isn't cached, show an informative toast and open the stat
block panel so they can load the source directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 12:11:20 +01:00
Lukas
f024562a7d Auto-open stat block panel when adding first bestiary creature
When the side panel is in its initial closed state (not user-collapsed),
adding a combatant from the bestiary now opens the panel to show its
stat block. This makes the panel discoverable without overriding a
deliberate collapse.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 12:03:34 +01:00
Lukas
dfef2194a5 Add subtle radial gradient to app background
All checks were successful
CI / check (push) Successful in 1m25s
CI / build-image (push) Successful in 20s
Apply a soft blue radial glow centered on the viewport to add depth
to the dark background, replacing the flat solid color.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 14:27:38 +01:00
Lukas
502adca81b Fix layout shift on turn change and restore concentration border width
All checks were successful
CI / check (push) Successful in 1m26s
CI / build-image (push) Successful in 21s
Give all combatant rows a consistent border-l-2 + border on all sides
(transparent when inactive) so toggling active/concentration states
never changes the row's box size. Show purple left border when a
combatant is both active and concentrating.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 13:57:02 +01:00
Lukas
12e8bf6e69 Constrain rename input width to prevent row layout breakage
Cap the editable name input at max-w-48 so it doesn't stretch the
full column width and push icons/conditions onto separate lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 13:50:48 +01:00
Lukas
472574ac31 Bump border radius tokens for rounder UI surfaces
All checks were successful
CI / check (push) Successful in 1m23s
CI / build-image (push) Successful in 20s
Increase radius-md from 6px to 8px and radius-lg from 8px to 12px
for a more modern, polished look on buttons, inputs, and card surfaces.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 13:48:23 +01:00
Lukas
f4a7b53393 Restyle dark theme with blue-tinted palette, card glow, and rounded surfaces
Shift the dark theme from neutral gray to a richer blue-tinted palette
inspired by CharBuilder-style TTRPG apps. Deeper navy background, steel-blue
card surfaces, and visible blue borders create more depth and visual layering.

- Update design tokens: background, card, border, input, muted colors
- Add card-glow utility (radial gradient + blue box-shadow) for card surfaces
- Add panel-glow utility (top-down gradient) for tall panels like stat blocks
- Apply glow and rounded-lg to all card surfaces, dropdowns, dialogs, toasts
- Give outline buttons a subtle fill instead of transparent background
- Active combatant row now uses full border with glow instead of left accent

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 13:39:44 +01:00
Lukas
8aec460ee4 Fix production class extraction by replacing template-literal classNames with cn()
All checks were successful
CI / check (push) Successful in 1m22s
CI / build-image (push) Successful in 29s
Tailwind v4's static extractor missed classes adjacent to ${} in template
literals (e.g. `pb-8${...}`), causing missing styles in production builds.
Migrated all dynamic classNames to cn() and added a check script to prevent
regressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 12:56:15 +01:00
Lukas
6e10238fe0 Add filter input to source manager for searching cached sources by name
All checks were successful
CI / check (push) Successful in 1m22s
CI / build-image (push) Successful in 18s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 12:05:45 +01:00
Lukas
b6e882add2 Add explicit text-foreground to ghost and outline button variants
Buttons should declare their own text color rather than relying on
inheritance, which breaks in contexts like native <dialog>. Remove
the text-foreground workaround from the dialog elements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:57:27 +01:00
Lukas
7a87d979bf Fix native dialog centering and text color
All checks were successful
CI / check (push) Successful in 1m24s
CI / build-image (push) Successful in 19s
Tailwind v4 preflight resets dialog margins and color inheritance.
Add m-auto to restore showModal() centering, and text-foreground
so ghost buttons inherit the correct color.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:55:18 +01:00
Lukas
02096bcee6 Fix bottom bar clipping on hosted deployment
All checks were successful
CI / check (push) Successful in 1m29s
CI / build-image (push) Successful in 31s
Use h-dvh (100dvh) instead of h-screen (100vh) so the layout
accounts for browser chrome (address bar, bottom toolbar).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:50:59 +01:00
Lukas
c092192b0e Add _copy field to RawMonster, remove noExplicitAny biome-ignore
The _copy field is a real property in the raw bestiary JSON — adding it
to the interface is more accurate than casting through any. Ratchet
source ignore threshold from 3 to 2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:31:34 +01:00
Lukas
4d1a7c6420 Remove noAwaitInLoops biome-ignore by chaining batches with reduce
Replace the for-loop with await-in-loop with a .reduce() chain that
sequences Promise.allSettled batches without triggering the lint rule.
Ratchet source ignore threshold from 4 to 3.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:24:31 +01:00
Lukas
46b444caba Refactor combatant row: single-click rename, book icon for stat blocks
Replace 250ms click timer and double-click detection with immediate
single-click rename for all combatant types. Add a BookOpen icon before
the name on bestiary rows as the dedicated stat block trigger. Remove
auto-show stat block on turn advance. Update specs to match: consistent
collapse/expand terminology, book icon requirements, no row-click stat
block behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:14:28 +01:00
Lukas
e68145319f Add biome-ignore backpressure script, convert modals to native <dialog>
Adds scripts/check-lint-ignores.mjs with four enforcement mechanisms:
ratcheting count cap (12 source / 3 test), banned rule prefixes,
required justification, and separate test thresholds. Wired into
pnpm check.

Converts player-management and create-player-modal from div-based
modals to native <dialog> with showModal()/close(), removing 8
biome-ignore comments. Remaining ignores are legitimate (Biome
false positives or stopPropagation wrappers with no fitting role).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 16:21:46 +01:00
Lukas
d64e1f5e4a Add integration tests for App.tsx with accessible HP status labels
3 integration tests render the full <App /> and exercise multi-component
flows: add/remove combatant, turn tracking across two combatants, and
HP adjustment with unconscious state. Add aria-label to the clickable HP
button so tests query accessible names instead of CSS classes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 01:46:32 +01:00
Lukas
ef0b755eec Add coverage thresholds for all tested directories, exclude dist from coverage
All checks were successful
CI / check (push) Successful in 1m4s
CI / build-image (push) Successful in 27s
Adds threshold entries for application, hooks, components, and components/ui
directories. Ratchets existing thresholds to match actual coverage. Excludes
**/dist/** from coverage to remove build output noise.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 16:19:56 +01:00
Lukas
4be816d10f Add test coverage for 5 components: HpAdjustPopover, ConditionPicker, CombatantRow, ActionBar, SourceManager
Adds aria-label attributes to HP placeholder and source delete buttons
for both accessibility and testability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:53:01 +01:00
Lukas
e531d82d1b Add test coverage for 3 hooks: useEncounter, usePlayerCharacters, useSidePanelState
29 tests covering state transitions, persistence sync, domain error
propagation, bestiary/PC add flows, and panel state machine logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:38:51 +01:00
Lukas
5a262c66cd Add test coverage for all 17 application layer use cases
Tests verify the get→call→save wiring and error propagation for each
use case. The 15 formulaic use cases share a test file; rollInitiative
and rollAllInitiative have dedicated suites covering their multi-step
logic (creature lookup, modifier calculation, iteration, early return).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:31:35 +01:00
Lukas
32b69f8df1 Use Readonly props and optional chaining/nullish coalescing
Mark component props as Readonly<> across 15 component files and
simplify edit-player-character field access with optional chaining
and nullish coalescing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:13:39 +01:00
Lukas
8efba288f7 Use String.raw and RegExp.exec, add prefer-regexp-exec oxlint rule
Replace escaped backslash in template literal with String.raw in
auto-number.ts. Use RegExp#exec() instead of String#match() in
bestiary-adapter.ts. Enable typescript/prefer-regexp-exec in oxlint
for automated enforcement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 14:55:54 +01:00
Lukas
c94c30e459 Add oxlint for type-aware linting that Biome cannot cover
Install oxlint with tsgolint for TypeScript type information. Enable
rules for unnecessary type assertions, deprecated API usage, preferring
replaceAll over replace with global regex, and String.raw for escaped
backslashes. Fix all violations: remove redundant as-casts, replace
deprecated FormEvent with SubmitEvent, convert replace(/g) to
replaceAll, and use String.raw in escapeRegExp. Add oxlint to the
pnpm check gate alongside Biome.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 14:41:30 +01:00
Lukas
36768d3aa1 Upgrade Biome to 2.4.7 and enable 54 additional lint rules
Add rules covering bug prevention (noLeakedRender, noFloatingPromises,
noImportCycles, noReactForwardRef), security (noScriptUrl, noAlert),
performance (noAwaitInLoops, useTopLevelRegex), and code style
(noNestedTernary, useGlobalThis, useNullishCoalescing, useSortedClasses,
plus ~40 more). Fix all violations: extract top-level regex constants,
guard React && renders with boolean coercion, refactor nested ternaries,
replace window with globalThis, sort Tailwind classes, and introduce
expectDomainError test helper to eliminate conditional expects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 14:25:09 +01:00
Lukas
473f1eaefe Exclude agent plan files from version control
All checks were successful
CI / check (push) Successful in 46s
CI / build-image (push) Successful in 26s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:10:18 +01:00
Lukas
971e0ded49 Replace ref+tick workaround with proper state in useBestiary
Store creature map in useState instead of useRef with a dummy
tick counter. React now re-renders naturally when the map changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:02:24 +01:00
Lukas
36dcfc5076 Use useOptimistic for instant source manager cache clearing
Source rows disappear immediately when cleared instead of waiting
for the IndexedDB operation to complete. Real state syncs after.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:02:24 +01:00
Lukas
127ed01064 Add self-review checklist to CLAUDE.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:02:24 +01:00
Lukas
179c3658ad Use useDeferredValue for search dropdown rendering
Defer rendering of bestiary suggestions and player character matches
in ActionBar so the input stays responsive as the bestiary grows.
Keyboard navigation and selection logic still use the latest values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:02:23 +01:00
Lukas
01f2bb3ff1 Move derived encounter flags into useEncounter() hook
Relocate isEmpty, hasCreatureCombatants, and canRollAllInitiative
from App.tsx into useEncounter(), reducing inline derivations in
the component (Phase 5 of App decomposition plan).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:02:23 +01:00
Lukas
930301de71 Rename fold/unfold to collapse/expand across panel code
Aligns terminology with standard UI conventions. Renames props,
state, handlers, aria-labels, test descriptions, and the test file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:02:23 +01:00
Lukas
aa806d4fb9 Show bulk import toast when panel is folded
Previously the toast only showed when the panel was not in bulk-import
mode. Now it also shows when the panel is folded, since the user can't
see the in-panel progress indicator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:02:23 +01:00
Lukas
61bc274715 Extract BulkImportToasts component from App.tsx
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:02:23 +01:00
Lukas
1932e837fb Extract PlayerCharacterSection component from App.tsx
Move player character modal state (createPlayerOpen, managementOpen,
editingPlayer) into a self-contained component with an imperative ref
handle. Closing the create/edit modal now returns to management.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:02:14 +01:00
Lukas
cce87318fb Extract useSidePanelState hook from App.tsx
Move panel view state, fold/pin state, isWideDesktop media query,
and all related handlers into a dedicated hook, reducing App.tsx
by ~80 lines of state management boilerplate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:02:03 +01:00
Lukas
3ef2370a34 Replace panel mode booleans with discriminated union
Three mutually exclusive state variables (selectedCreatureId,
bulkImportMode, sourceManagerMode) replaced with a single PanelView
union type, eliminating impossible states and boolean-clearing
boilerplate in handlers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:02:03 +01:00
Lukas
c75d148d1e Fix high-severity undici vulnerability via pnpm override
Override undici to >=7.24.0 to resolve GHSA-v9p9-hfj2-hcw8
(WebSocket 64-bit length overflow). The vulnerable version was
pulled in transitively via jsdom@28.1.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:02:03 +01:00
Lukas
63e233bd8d Switch side panel to stat block when advancing turns
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 18s
Previously the import/sources view would stay open when navigating to
the next combatant. Now advancing turns clears those modes so the active
creature's stat block is shown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:03:11 +01:00
Lukas
8c62ec28f2 Rename "Bulk Import" to "Import All Sources" and remove file size mention
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:01:03 +01:00
Lukas
72195e90f6 Show toast when roll-all-initiative skips combatants without loaded sources
Previously the button silently did nothing for creatures whose bestiary
source wasn't loaded. Now it reports how many were skipped and why. Also
keeps the roll-all button visible (but disabled) when there's nothing
left to roll, and moves toasts to the bottom-left corner.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:58:25 +01:00
Lukas
6ac8e67970 Move source manager from combatant area to side panel
Source management now opens in the right side panel (like bulk import
and stat blocks) instead of rendering inline above the combatant list.
All three panel modes properly clear each other on activation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:40:28 +01:00
Lukas
a4797d5b15 Unfold side panel when triggering bulk import from overflow menu
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:33:05 +01:00
Lukas
d48e39ced4 Fix input not clearing after adding player character from suggestions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:30:24 +01:00
Lukas
b7406c4b54 Make player character color and icon optional
Clicking an already-selected color or icon in the create/edit form now
deselects it. PCs without a color use the default combatant styling;
PCs without an icon show no icon. Domain, application, persistence,
and display layers all updated to handle the optional fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:01:20 +01:00
Lukas
07cdd4867a Fix custom combatant form disappearing when dismissing suggestions
All checks were successful
CI / check (push) Successful in 46s
CI / build-image (push) Successful in 18s
The "Add as custom" button and Escape key were clearing the name input
along with the suggestions, preventing the custom fields (Init, AC,
MaxHP) from ever appearing. Now only the suggestions are dismissed,
keeping the typed name intact so the custom combatant form renders.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:45:39 +01:00
178 changed files with 13728 additions and 3650 deletions

View File

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

View File

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

2
.gitignore vendored
View File

@@ -11,3 +11,5 @@ Thumbs.db
.idea/
coverage/
*.tsbuildinfo
docs/agents/plans/
.rodney/

27
.oxlintrc.json Normal file
View File

@@ -0,0 +1,27 @@
{
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/tc39-proposal-type-annotations/refs/heads/main/packages/oxlint/configuration_file_schema.json",
"plugins": ["typescript", "unicorn", "jest"],
"categories": {},
"rules": {
"typescript/no-unnecessary-type-assertion": "error",
"typescript/no-deprecated": "warn",
"typescript/prefer-regexp-exec": "error",
"unicorn/prefer-string-replace-all": "error",
"unicorn/prefer-string-raw": "error",
"jest/expect-expect": [
"error",
{
"assertFunctionNames": ["expect", "expectDomainError"]
}
]
},
"ignorePatterns": [
"dist",
"coverage",
".claude",
".specify",
"specs",
".pnpm-store",
"scripts"
]
}

View File

@@ -1,9 +1,9 @@
<!--
Sync Impact Report
───────────────────
Version change: 2.2.1 → 3.0.0 (MAJOR — specs describe features not changes, proportional workflow)
Version change: 3.0.0 → 3.1.0 (MINOR — new principle II-A: context-based state flow)
Modified sections:
- Development Workflow: specs are living feature documents; full pipeline for new features only
- Core Principles: added II-A. Context-Based State Flow (max 8 props, context over prop drilling)
Templates requiring updates: none
-->
# Encounter Console Constitution
@@ -38,6 +38,22 @@ dependency direction:
A module in an inner layer MUST NOT import from an outer layer.
### II-A. Context-Based State Flow
UI components MUST consume shared application state via React context
providers, not prop drilling. Props are reserved for per-instance
configuration (e.g., a specific data item, a layout variant, a ref).
- Components MUST NOT declare more than 8 explicit props in their
own interface. This is enforced by `scripts/check-component-props.mjs`
at pre-commit.
- Generic UI primitives (`components/ui/`) that extend HTML element
attributes are exempt — only explicitly declared props count, not
inherited HTML attributes.
- Coordinating hooks that consume multiple contexts (e.g.,
`useInitiativeRolls`) are preferred over wiring callbacks through
a parent component.
### III. Clarification-First
Before making any non-trivial assumption during specification,
@@ -140,4 +156,4 @@ MUST comply with its principles.
**Compliance review**: Every spec and plan MUST include a
Constitution Check section validating adherence to all principles.
**Version**: 3.0.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-11
**Version**: 3.1.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-19

View File

@@ -5,13 +5,15 @@ 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 + typecheck + test/coverage + jscpd)
pnpm check # Merge gate — must pass before every commit (audit + knip + biome + oxlint + typecheck + test/coverage + jscpd)
pnpm oxlint # Type-aware linting (oxlint — complements Biome)
pnpm knip # Unused code detection (Knip)
pnpm test # Run all tests (Vitest)
pnpm test:watch # Tests in watch mode
pnpm typecheck # tsc --build (project references)
pnpm lint # Biome lint
pnpm format # Biome format (writes)
pnpm check:props # Component prop count enforcement (max 8)
pnpm --filter web dev # Vite dev server (localhost:5173)
pnpm --filter web build # Production build
```
@@ -58,19 +60,29 @@ 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.0 (formatting + linting), Knip (unused code), jscpd (copy-paste detection)
- Biome 2.4 (formatting + linting), oxlint (type-aware linting), Knip (unused code), jscpd (copy-paste detection)
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
## Conventions
- **Biome 2.0** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
- **Biome 2.4** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
- **oxlint** for type-aware linting that Biome can't do (unnecessary type assertions, deprecated APIs, `replaceAll` preference, `String.raw`). Configured in `.oxlintrc.json`.
- **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports when required by the repo's ESM settings (e.g., `./types.js`).
- **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical.
- **Domain events** are plain data objects with a `type` discriminant — no classes.
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks.
- **Feature specs** live in `specs/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.
## Self-Review Checklist
Before finishing a change, consider:
- Is this the simplest approach that solves the current problem?
- Is there duplication that hurts readability? (But don't abstract prematurely.)
- Are errors handled correctly and communicated sensibly to the user?
- Does the UI follow modern patterns and feel intuitive to interact with?
## Speckit Workflow
Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Specs are **living documents** that describe features, not individual changes.
@@ -101,6 +113,7 @@ Speckit manages **what** to build (specs as living documents). RPI manages **how
- `specs/002-turn-tracking/` — rounds, turn order, advance/retreat, top bar
- `specs/003-combatant-state/` — HP, AC, conditions, concentration, initiative
- `specs/004-bestiary/` — search index, stat blocks, source management, panel UX
- `specs/005-player-characters/` — persistent player character templates (CRUD), search & add to encounters, color/icon visual distinction, `PlayerCharacterStore` port
## Constitution (key principles)
@@ -111,3 +124,4 @@ 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.

View File

@@ -7,7 +7,9 @@ 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
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB
- **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
- **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
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB; player characters stored independently
## Prerequisites

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>

View File

@@ -20,14 +20,15 @@
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@tailwindcss/vite": "^4.2.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"jsdom": "^28.1.0",
"tailwindcss": "^4.2.1",
"vite": "^6.2.0"
"@vitejs/plugin-react": "^6.0.1",
"jsdom": "^29.0.1",
"tailwindcss": "^4.2.2",
"vite": "^8.0.1"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

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
apps/web/public/icon.svg Normal file
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

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"
}
]
}

View File

@@ -1,326 +1,140 @@
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";
import {
rollAllInitiativeUseCase,
rollInitiativeUseCase,
} from "@initiative/application";
import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActionBar } from "./components/action-bar";
import { CombatantRow } from "./components/combatant-row";
import { SourceManager } from "./components/source-manager";
import { StatBlockPanel } from "./components/stat-block-panel";
import { Toast } from "./components/toast";
import { TurnNavigation } from "./components/turn-navigation";
import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
import { useBulkImport } from "./hooks/use-bulk-import";
import { useEncounter } from "./hooks/use-encounter";
function rollDice(): number {
return Math.floor(Math.random() * 20) + 1;
}
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";
import { useEncounterContext } from "./contexts/encounter-context.js";
import { useInitiativeRollsContext } from "./contexts/initiative-rolls-context.js";
import { useSidePanelContext } from "./contexts/side-panel-context.js";
import { useActionBarAnimation } from "./hooks/use-action-bar-animation.js";
import { useAutoStatBlock } from "./hooks/use-auto-stat-block.js";
import { cn } from "./lib/utils.js";
export function App() {
const {
encounter,
advanceTurn,
retreatTurn,
addCombatant,
clearEncounter,
removeCombatant,
editCombatant,
setInitiative,
setHp,
adjustHp,
setAc,
toggleCondition,
toggleConcentration,
addFromBestiary,
makeStore,
} = useEncounter();
const { encounter, isEmpty } = useEncounterContext();
const sidePanel = useSidePanelContext();
const rolls = useInitiativeRollsContext();
const {
search,
getCreature,
isLoaded,
isSourceCached,
fetchAndCacheSource,
uploadAndCacheSource,
refreshCache,
} = useBestiary();
useAutoStatBlock();
const bulkImport = useBulkImport();
const [selectedCreatureId, setSelectedCreatureId] =
useState<CreatureId | null>(null);
const [bulkImportMode, setBulkImportMode] = useState(false);
const [sourceManagerOpen, setSourceManagerOpen] = useState(false);
const [isRightPanelFolded, setIsRightPanelFolded] = useState(false);
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
null,
);
const [isWideDesktop, setIsWideDesktop] = useState(
() => window.matchMedia("(min-width: 1280px)").matches,
);
useEffect(() => {
const mq = window.matchMedia("(min-width: 1280px)");
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const selectedCreature: Creature | null = selectedCreatureId
? (getCreature(selectedCreatureId) ?? null)
: null;
const pinnedCreature: Creature | null = pinnedCreatureId
? (getCreature(pinnedCreatureId) ?? null)
: null;
const handleAddFromBestiary = useCallback(
(result: SearchResult) => {
addFromBestiary(result);
// Derive the creature ID so stat block panel can try to show it
const slug = result.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
setSelectedCreatureId(
`${result.source.toLowerCase()}:${slug}` as CreatureId,
);
},
[addFromBestiary],
);
const handleCombatantStatBlock = useCallback((creatureId: string) => {
setSelectedCreatureId(creatureId as CreatureId);
setIsRightPanelFolded(false);
}, []);
const handleRollInitiative = useCallback(
(id: CombatantId) => {
rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature);
},
[makeStore, getCreature],
);
const handleRollAllInitiative = useCallback(() => {
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
}, [makeStore, getCreature]);
const handleViewStatBlock = useCallback((result: SearchResult) => {
const slug = result.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
setSelectedCreatureId(cId);
setIsRightPanelFolded(false);
}, []);
const handleBulkImport = useCallback(() => {
setBulkImportMode(true);
setSelectedCreatureId(null);
}, []);
const handleStartBulkImport = useCallback(
(baseUrl: string) => {
bulkImport.startImport(
baseUrl,
fetchAndCacheSource,
isSourceCached,
refreshCache,
);
},
[bulkImport.startImport, fetchAndCacheSource, isSourceCached, refreshCache],
);
const handleBulkImportDone = useCallback(() => {
setBulkImportMode(false);
bulkImport.reset();
}, [bulkImport.reset]);
const handleDismissBrowsePanel = useCallback(() => {
setSelectedCreatureId(null);
setBulkImportMode(false);
}, []);
const handleToggleFold = useCallback(() => {
setIsRightPanelFolded((f) => !f);
}, []);
const handlePin = useCallback(() => {
if (selectedCreatureId) {
setPinnedCreatureId((prev) =>
prev === selectedCreatureId ? null : selectedCreatureId,
);
}
}, [selectedCreatureId]);
const handleUnpin = useCallback(() => {
setPinnedCreatureId(null);
}, []);
// Auto-scroll to the active combatant when the turn changes
const [settingsOpen, setSettingsOpen] = useState(false);
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
const actionBarInputRef = useRef<HTMLInputElement>(null);
const activeRowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
activeRowRef.current?.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}, [encounter.activeIndex]);
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
// Auto-show stat block for the active combatant when turn changes,
// but only when the viewport is wide enough to show it alongside the tracker.
// Only react to activeIndex changes — not combatant reordering (e.g. Roll All).
const prevActiveIndexRef = useRef(encounter.activeIndex);
// Auto-scroll to active combatant when turn changes
const activeIndex = encounter.activeIndex;
useEffect(() => {
if (prevActiveIndexRef.current === encounter.activeIndex) return;
prevActiveIndexRef.current = encounter.activeIndex;
if (!window.matchMedia("(min-width: 1024px)").matches) return;
const active = encounter.combatants[encounter.activeIndex];
if (!active?.creatureId || !isLoaded) return;
setSelectedCreatureId(active.creatureId as CreatureId);
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
if (activeIndex >= 0) {
activeRowRef.current?.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}, [activeIndex]);
return (
<div className="flex h-screen flex-col">
<div className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0">
{/* Turn Navigation — fixed at top */}
<div className="shrink-0 pt-8">
<TurnNavigation
encounter={encounter}
onAdvanceTurn={advanceTurn}
onRetreatTurn={retreatTurn}
onClearEncounter={clearEncounter}
onRollAllInitiative={handleRollAllInitiative}
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
/>
</div>
{sourceManagerOpen && (
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
<SourceManager onCacheCleared={refreshCache} />
<div className="flex h-dvh flex-col">
<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-[env(safe-area-inset-top)] sm:pt-[max(env(safe-area-inset-top),2rem)]",
actionBarAnim.topBarClass,
)}
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
>
<TurnNavigation />
</div>
)}
{/* Scrollable area — combatant list */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="flex flex-col px-2 py-2">
{encounter.combatants.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground">
No combatants yet add one to get started
</p>
) : (
encounter.combatants.map((c, i) => (
<CombatantRow
key={c.id}
ref={i === encounter.activeIndex ? activeRowRef : null}
combatant={c}
isActive={i === encounter.activeIndex}
onRename={editCombatant}
onSetInitiative={setInitiative}
onRemove={removeCombatant}
onSetHp={setHp}
onAdjustHp={adjustHp}
onSetAc={setAc}
onToggleCondition={toggleCondition}
onToggleConcentration={toggleConcentration}
onShowStatBlock={
c.creatureId
? () => handleCombatantStatBlock(c.creatureId as string)
: undefined
}
onRollInitiative={
c.creatureId ? handleRollInitiative : undefined
}
/>
))
)}
{isEmpty ? (
<div className="flex min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]">
<div
className={cn("w-full", actionBarAnim.risingClass)}
onAnimationEnd={actionBarAnim.onRiseEnd}
>
<ActionBar
inputRef={actionBarInputRef}
onManagePlayers={() =>
playerCharacterRef.current?.openManagement()
}
onOpenSettings={() => setSettingsOpen(true)}
autoFocus
/>
</div>
</div>
</div>
) : (
<>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="flex flex-col px-2 py-2">
{encounter.combatants.map((c, i) => (
<CombatantRow
key={c.id}
ref={i === encounter.activeIndex ? activeRowRef : null}
combatant={c}
isActive={i === encounter.activeIndex}
/>
))}
</div>
</div>
{/* Action Bar — fixed at bottom */}
<div className="shrink-0 pb-8">
<ActionBar
onAddCombatant={addCombatant}
onAddFromBestiary={handleAddFromBestiary}
bestiarySearch={search}
bestiaryLoaded={isLoaded}
onViewStatBlock={handleViewStatBlock}
onBulkImport={handleBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"}
/>
</div>
<div
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
inputRef={actionBarInputRef}
onManagePlayers={() =>
playerCharacterRef.current?.openManagement()
}
onOpenSettings={() => setSettingsOpen(true)}
/>
</div>
</>
)}
</div>
{/* Pinned Stat Block Panel (left) */}
{pinnedCreatureId && isWideDesktop && (
<StatBlockPanel
creatureId={pinnedCreatureId}
creature={pinnedCreature}
isSourceCached={isSourceCached}
fetchAndCacheSource={fetchAndCacheSource}
uploadAndCacheSource={uploadAndCacheSource}
refreshCache={refreshCache}
panelRole="pinned"
isFolded={false}
onToggleFold={() => {}}
onPin={() => {}}
onUnpin={handleUnpin}
showPinButton={false}
side="left"
onDismiss={() => {}}
{!!sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
<StatBlockPanel panelRole="pinned" side="left" />
)}
<StatBlockPanel panelRole="browse" side="right" />
<BulkImportToasts />
{rolls.rollSkippedCount > 0 && (
<Toast
message={`${rolls.rollSkippedCount} skipped — bestiary source not loaded`}
onDismiss={rolls.dismissRollSkipped}
autoDismissMs={4000}
/>
)}
{/* Browse Stat Block Panel (right) */}
<StatBlockPanel
creatureId={selectedCreatureId}
creature={selectedCreature}
isSourceCached={isSourceCached}
fetchAndCacheSource={fetchAndCacheSource}
uploadAndCacheSource={uploadAndCacheSource}
refreshCache={refreshCache}
panelRole="browse"
isFolded={isRightPanelFolded}
onToggleFold={handleToggleFold}
onPin={handlePin}
onUnpin={() => {}}
showPinButton={isWideDesktop && !!selectedCreature}
side="right"
onDismiss={handleDismissBrowsePanel}
bulkImportMode={bulkImportMode}
bulkImportState={bulkImport.state}
onStartBulkImport={handleStartBulkImport}
onBulkImportDone={handleBulkImportDone}
{!!rolls.rollSingleSkipped && (
<Toast
message="Can't roll — bestiary source not loaded"
onDismiss={rolls.dismissRollSingleSkipped}
autoDismissMs={4000}
/>
)}
<SettingsModal
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
/>
{/* Toast for bulk import progress when panel is closed */}
{bulkImport.state.status === "loading" && !bulkImportMode && (
<Toast
message={`Loading sources... ${bulkImport.state.completed + bulkImport.state.failed}/${bulkImport.state.total}`}
progress={
bulkImport.state.total > 0
? (bulkImport.state.completed + bulkImport.state.failed) /
bulkImport.state.total
: 0
}
onDismiss={() => {}}
/>
)}
{bulkImport.state.status === "complete" && !bulkImportMode && (
<Toast
message="All sources loaded"
onDismiss={bulkImport.reset}
autoDismissMs={3000}
/>
)}
{bulkImport.state.status === "partial-failure" && !bulkImportMode && (
<Toast
message={`Loaded ${bulkImport.state.completed}/${bulkImport.state.total} sources (${bulkImport.state.failed} failed)`}
onDismiss={bulkImport.reset}
/>
)}
<PlayerCharacterSection ref={playerCharacterRef} />
</div>
);
}

View File

@@ -0,0 +1,163 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
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 { App } from "../App.js";
import { AllProviders } from "./test-providers.js";
// Mock persistence — no localStorage interaction
vi.mock("../persistence/encounter-storage.js", () => ({
loadEncounter: () => null,
saveEncounter: () => {},
}));
vi.mock("../persistence/player-character-storage.js", () => ({
loadPlayerCharacters: () => [],
savePlayerCharacters: () => {},
}));
// Mock bestiary — no IndexedDB or JSON index
vi.mock("../adapters/bestiary-cache.js", () => ({
loadAllCachedCreatures: () => Promise.resolve(new Map()),
isSourceCached: () => Promise.resolve(false),
cacheSource: () => Promise.resolve(),
getCachedSources: () => Promise.resolve([]),
clearSource: () => Promise.resolve(),
clearAll: () => Promise.resolve(),
}));
vi.mock("../adapters/bestiary-index-adapter.js", () => ({
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
getAllSourceCodes: () => [],
getDefaultFetchUrl: () => "",
getSourceDisplayName: (code: string) => code,
}));
// DOM API stubs — jsdom doesn't implement these
beforeAll(() => {
Object.defineProperty(globalThis, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
Element.prototype.scrollIntoView = vi.fn();
});
afterEach(cleanup);
async function addCombatant(
user: ReturnType<typeof userEvent.setup>,
name: string,
opts?: { maxHp?: string },
) {
const inputs = screen.getAllByPlaceholderText("+ Add combatants");
// biome-ignore lint/style/noNonNullAssertion: getAllBy always returns at least one
const input = inputs.at(-1)!;
await user.type(input, name);
if (opts?.maxHp) {
const maxHpInput = screen.getByPlaceholderText("MaxHP");
await user.type(maxHpInput, opts.maxHp);
}
const addButton = screen.getByRole("button", { name: "Add" });
await user.click(addButton);
}
describe("App integration", () => {
it("adds a combatant and removes it, returning to empty state", async () => {
const user = userEvent.setup();
render(<App />, { wrapper: AllProviders });
// Empty state: centered input visible, no TurnNavigation
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
expect(screen.queryByText("R1")).not.toBeInTheDocument();
// Add a combatant
await addCombatant(user, "Goblin");
// Verify combatant appears and TurnNavigation shows
expect(screen.getByRole("button", { name: "Goblin" })).toBeInTheDocument();
expect(screen.getByText("R1")).toBeInTheDocument();
expect(screen.getAllByText("Goblin").length).toBeGreaterThanOrEqual(2);
// Remove combatant via ConfirmButton (two clicks)
const removeBtn = screen.getByRole("button", {
name: "Remove combatant",
});
await user.click(removeBtn);
const confirmBtn = screen.getByRole("button", {
name: "Confirm remove combatant",
});
await user.click(confirmBtn);
// Back to empty state (R1 badge may linger due to exit animation in jsdom)
expect(
screen.queryByRole("button", { name: "Goblin" }),
).not.toBeInTheDocument();
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
});
it("advances and retreats turns across two combatants", async () => {
const user = userEvent.setup();
render(<App />, { wrapper: AllProviders });
await addCombatant(user, "Fighter");
await addCombatant(user, "Wizard");
// Initial state — R1, Fighter active (Previous turn disabled)
expect(screen.getByText("R1")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Previous turn" }),
).toBeDisabled();
// Advance turn — Wizard becomes active
await user.click(screen.getByRole("button", { name: "Next turn" }));
expect(screen.getByText("R1")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Previous turn" })).toBeEnabled();
// Advance again — wraps to R2, Fighter active
await user.click(screen.getByRole("button", { name: "Next turn" }));
expect(screen.getByText("R2")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Previous turn" })).toBeEnabled();
// Retreat — back to R1, Wizard active
await user.click(screen.getByRole("button", { name: "Previous turn" }));
expect(screen.getByText("R1")).toBeInTheDocument();
});
it("adds a combatant with HP, applies damage, and shows unconscious state", async () => {
const user = userEvent.setup();
render(<App />, { wrapper: AllProviders });
await addCombatant(user, "Ogre", { maxHp: "59" });
// Verify HP displays — currentHp and maxHp both show "59"
const hpButton = screen.getByRole("button", {
name: "Current HP: 59 (healthy)",
});
expect(hpButton).toBeInTheDocument();
// Click currentHp to open HpAdjustPopover, apply full damage
await user.click(hpButton);
const hpInput = screen.getByPlaceholderText("HP");
expect(hpInput).toBeInTheDocument();
await user.type(hpInput, "59");
await user.click(screen.getByRole("button", { name: "Apply damage" }));
// Verify HP decreased to 0 and unconscious state
expect(
screen.getByRole("button", { name: "Current HP: 0 (unconscious)" }),
).toBeInTheDocument();
});
});

View File

@@ -200,6 +200,7 @@ describe("ConfirmButton", () => {
const parentHandler = vi.fn();
render(
// biome-ignore lint/a11y/noStaticElementInteractions: test wrapper
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: test wrapper
<div onKeyDown={parentHandler}>
<ConfirmButton
icon={<XIcon />}

View File

@@ -0,0 +1,317 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import type { Creature, CreatureId } from "@initiative/domain";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Mock the context modules
vi.mock("../contexts/side-panel-context.js", () => ({
useSidePanelContext: vi.fn(),
}));
vi.mock("../contexts/bestiary-context.js", () => ({
useBestiaryContext: vi.fn(),
}));
// Mock adapters to avoid IndexedDB
vi.mock("../adapters/bestiary-index-adapter.js", () => ({
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
getAllSourceCodes: () => [],
getDefaultFetchUrl: () => "",
getSourceDisplayName: (code: string) => code,
}));
import { StatBlockPanel } from "../components/stat-block-panel.js";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js";
const mockUseSidePanelContext = vi.mocked(useSidePanelContext);
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
const CLOSE_REGEX = /close/i;
const COLLAPSE_REGEX = /collapse/i;
const CREATURE_ID = "srd:goblin" as CreatureId;
const CREATURE: Creature = {
id: CREATURE_ID,
name: "Goblin",
source: "SRD",
sourceDisplayName: "SRD",
size: "Small",
type: "humanoid",
alignment: "neutral evil",
ac: 15,
hp: { average: 7, formula: "2d6" },
speed: "30 ft.",
abilities: { str: 8, dex: 14, con: 10, int: 10, wis: 8, cha: 8 },
cr: "1/4",
initiativeProficiency: 0,
proficiencyBonus: 2,
passive: 9,
};
function mockMatchMedia(matches: boolean) {
Object.defineProperty(globalThis, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
interface PanelOverrides {
creatureId?: CreatureId | null;
creature?: Creature | null;
panelRole?: "browse" | "pinned";
isCollapsed?: boolean;
side?: "left" | "right";
bulkImportMode?: boolean;
}
function setupMocks(overrides: PanelOverrides = {}) {
const panelRole = overrides.panelRole ?? "browse";
const creatureId = overrides.creatureId ?? CREATURE_ID;
const creature = overrides.creature ?? CREATURE;
const isCollapsed = overrides.isCollapsed ?? false;
const onToggleCollapse = vi.fn();
const onPin = vi.fn();
const onUnpin = vi.fn();
const onDismiss = vi.fn();
mockUseSidePanelContext.mockReturnValue({
selectedCreatureId: panelRole === "browse" ? creatureId : null,
pinnedCreatureId: panelRole === "pinned" ? creatureId : null,
isRightPanelCollapsed: panelRole === "browse" ? isCollapsed : false,
isWideDesktop: false,
bulkImportMode: overrides.bulkImportMode ?? false,
sourceManagerMode: false,
panelView: creatureId
? { mode: "creature" as const, creatureId }
: { mode: "closed" as const },
showCreature: vi.fn(),
updateCreature: vi.fn(),
showBulkImport: vi.fn(),
showSourceManager: vi.fn(),
dismissPanel: onDismiss,
toggleCollapse: onToggleCollapse,
togglePin: onPin,
unpin: onUnpin,
} as ReturnType<typeof useSidePanelContext>);
mockUseBestiaryContext.mockReturnValue({
getCreature: (id: CreatureId) => (id === creatureId ? creature : undefined),
isSourceCached: vi.fn().mockResolvedValue(true),
search: vi.fn().mockReturnValue([]),
isLoaded: true,
fetchAndCacheSource: vi.fn(),
uploadAndCacheSource: vi.fn(),
refreshCache: vi.fn(),
} as ReturnType<typeof useBestiaryContext>);
return { onToggleCollapse, onPin, onUnpin, onDismiss };
}
function renderPanel(overrides: PanelOverrides = {}) {
const callbacks = setupMocks(overrides);
const panelRole = overrides.panelRole ?? "browse";
const side = overrides.side ?? (panelRole === "pinned" ? "left" : "right");
render(<StatBlockPanel panelRole={panelRole} side={side} />);
return callbacks;
}
describe("Stat Block Panel Collapse/Expand and Pin", () => {
beforeEach(() => {
mockMatchMedia(true); // desktop by default
});
afterEach(cleanup);
describe("US1: Collapse and Expand", () => {
it("shows collapse button instead of close button on desktop", () => {
renderPanel();
expect(
screen.getByRole("button", { name: "Collapse stat block panel" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: CLOSE_REGEX }),
).not.toBeInTheDocument();
});
it("does not show 'Stat Block' heading", () => {
renderPanel();
expect(screen.queryByText("Stat Block")).not.toBeInTheDocument();
});
it("renders collapsed tab with creature name when isCollapsed is true", () => {
renderPanel({ isCollapsed: true });
expect(screen.getByText("Goblin")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Expand stat block panel" }),
).toBeInTheDocument();
});
it("calls onToggleCollapse when collapse button is clicked", () => {
const callbacks = renderPanel();
fireEvent.click(
screen.getByRole("button", { name: "Collapse stat block panel" }),
);
expect(callbacks.onToggleCollapse).toHaveBeenCalledTimes(1);
});
it("calls onToggleCollapse when collapsed tab is clicked", () => {
const callbacks = renderPanel({ isCollapsed: true });
fireEvent.click(
screen.getByRole("button", { name: "Expand stat block panel" }),
);
expect(callbacks.onToggleCollapse).toHaveBeenCalledTimes(1);
});
it("applies translate-x class when collapsed (right side)", () => {
renderPanel({ isCollapsed: true, side: "right" });
const panel = screen
.getByRole("button", { name: "Expand stat block panel" })
.closest("div");
expect(panel?.className).toContain("translate-x-[calc(100%-40px)]");
});
it("applies translate-x-0 when expanded", () => {
renderPanel({ isCollapsed: false });
const foldBtn = screen.getByRole("button", {
name: "Collapse stat block panel",
});
const panel = foldBtn.closest("div.fixed") as HTMLElement;
expect(panel?.className).toContain("translate-x-0");
});
});
describe("US1: Mobile behavior", () => {
beforeEach(() => {
mockMatchMedia(false); // mobile
});
it("shows collapse button instead of X close button on mobile drawer", () => {
renderPanel();
expect(
screen.getByRole("button", { name: "Collapse stat block panel" }),
).toBeInTheDocument();
// No X close icon button — only backdrop dismiss and collapse toggle
const buttons = screen.getAllByRole("button");
const buttonLabels = buttons.map((b) => b.getAttribute("aria-label"));
expect(buttonLabels).not.toContain("Close");
});
it("calls onDismiss when backdrop is clicked on mobile", () => {
const callbacks = renderPanel();
fireEvent.click(screen.getByRole("button", { name: "Close stat block" }));
expect(callbacks.onDismiss).toHaveBeenCalledTimes(1);
});
it("does not render pinned panel on mobile", () => {
const { container } = render(
(() => {
setupMocks({ panelRole: "pinned" });
return <StatBlockPanel panelRole="pinned" side="left" />;
})(),
);
expect(container.innerHTML).toBe("");
});
});
describe("US2: Pin and Unpin", () => {
it("shows pin button when isWideDesktop is true on desktop", () => {
setupMocks();
// Override to set isWideDesktop
const ctx = mockUseSidePanelContext.mock.results[0]?.value as ReturnType<
typeof useSidePanelContext
>;
mockUseSidePanelContext.mockReturnValue({
...ctx,
isWideDesktop: true,
});
render(<StatBlockPanel panelRole="browse" side="right" />);
expect(
screen.getByRole("button", { name: "Pin creature" }),
).toBeInTheDocument();
});
it("hides pin button when isWideDesktop is false", () => {
renderPanel();
expect(
screen.queryByRole("button", { name: "Pin creature" }),
).not.toBeInTheDocument();
});
it("calls onPin when pin button is clicked", () => {
setupMocks();
const ctx = mockUseSidePanelContext.mock.results[0]?.value as ReturnType<
typeof useSidePanelContext
>;
mockUseSidePanelContext.mockReturnValue({
...ctx,
isWideDesktop: true,
});
render(<StatBlockPanel panelRole="browse" side="right" />);
fireEvent.click(screen.getByRole("button", { name: "Pin creature" }));
expect(ctx.togglePin).toHaveBeenCalledTimes(1);
});
it("shows unpin button for pinned role", () => {
renderPanel({ panelRole: "pinned", side: "left" });
expect(
screen.getByRole("button", { name: "Unpin creature" }),
).toBeInTheDocument();
});
it("calls onUnpin when unpin button is clicked", () => {
const callbacks = renderPanel({ panelRole: "pinned", side: "left" });
fireEvent.click(screen.getByRole("button", { name: "Unpin creature" }));
expect(callbacks.onUnpin).toHaveBeenCalledTimes(1);
});
it("positions pinned panel on the left side", () => {
renderPanel({ panelRole: "pinned", side: "left" });
const unpinBtn = screen.getByRole("button", {
name: "Unpin creature",
});
const panel = unpinBtn.closest("div.fixed") as HTMLElement;
expect(panel?.className).toContain("left-0");
expect(panel?.className).toContain("border-r");
});
it("positions browse panel on the right side", () => {
renderPanel({ panelRole: "browse", side: "right" });
const foldBtn = screen.getByRole("button", {
name: "Collapse stat block panel",
});
const panel = foldBtn.closest("div.fixed") as HTMLElement;
expect(panel?.className).toContain("right-0");
expect(panel?.className).toContain("border-l");
});
});
describe("US3: Collapse independence with pinned panel", () => {
it("pinned panel has no collapse button", () => {
renderPanel({ panelRole: "pinned", side: "left" });
expect(
screen.queryByRole("button", { name: COLLAPSE_REGEX }),
).not.toBeInTheDocument();
});
it("pinned panel is always expanded (no translate offset)", () => {
renderPanel({ panelRole: "pinned", side: "left" });
const unpinBtn = screen.getByRole("button", {
name: "Unpin creature",
});
const panel = unpinBtn.closest("div.fixed") as HTMLElement;
expect(panel?.className).toContain("translate-x-0");
});
});
});

View File

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

View File

@@ -0,0 +1,31 @@
import type { ReactNode } from "react";
import {
BestiaryProvider,
BulkImportProvider,
EncounterProvider,
InitiativeRollsProvider,
PlayerCharactersProvider,
RulesEditionProvider,
SidePanelProvider,
ThemeProvider,
} from "../contexts/index.js";
export function AllProviders({ children }: { children: ReactNode }) {
return (
<ThemeProvider>
<RulesEditionProvider>
<EncounterProvider>
<BestiaryProvider>
<PlayerCharactersProvider>
<BulkImportProvider>
<SidePanelProvider>
<InitiativeRollsProvider>{children}</InitiativeRollsProvider>
</SidePanelProvider>
</BulkImportProvider>
</PlayerCharactersProvider>
</BestiaryProvider>
</EncounterProvider>
</RulesEditionProvider>
</ThemeProvider>
);
}

View File

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

View File

@@ -30,11 +30,11 @@ describe("stripTags", () => {
expect(stripTags("{@hit 5}")).toBe("+5");
});
it("strips {@h} to Hit: ", () => {
it("strips {@h} to Hit:", () => {
expect(stripTags("{@h}")).toBe("Hit: ");
});
it("strips {@hom} to Hit or Miss: ", () => {
it("strips {@hom} to Hit or Miss:", () => {
expect(stripTags("{@hom}")).toBe("Hit or Miss: ");
});
@@ -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)");
});

View File

@@ -9,6 +9,8 @@ import type {
import { creatureId, proficiencyBonus } from "@initiative/domain";
import { stripTags } from "./strip-tags.js";
const LEADING_DIGITS_REGEX = /^(\d+)/;
// --- Raw 5etools types (minimal, for parsing) ---
interface RawMonster {
@@ -49,6 +51,7 @@ interface RawMonster {
legendaryHeader?: string[];
spellcasting?: RawSpellcasting[];
initiative?: { proficiency?: number };
_copy?: unknown;
}
interface RawEntry {
@@ -168,7 +171,7 @@ function extractAc(ac: RawMonster["ac"]): {
}
if ("special" in first) {
// Variable AC (e.g. spell summons) — parse leading number if possible
const match = first.special.match(/^(\d+)/);
const match = LEADING_DIGITS_REGEX.exec(first.special);
return {
value: match ? Number(match[1]) : 0,
source: first.special,
@@ -371,8 +374,8 @@ function extractCr(cr: string | { cr: string } | undefined): string {
function makeCreatureId(source: string, name: string): CreatureId {
const slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/(^-|-$)/g, "");
return creatureId(`${source.toLowerCase()}:${slug}`);
}
@@ -383,8 +386,7 @@ export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
// Filter out _copy entries (reference another source's monster) and
// monsters missing required fields (ac, hp, size, type)
const monsters = raw.monster.filter((m) => {
// biome-ignore lint/suspicious/noExplicitAny: raw JSON may have _copy field
if ((m as any)._copy) return false;
if (m._copy) return false;
return (
Array.isArray(m.ac) &&
m.ac.length > 0 &&

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;

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.
*
@@ -25,55 +34,63 @@ export function stripTags(text: string): string {
let result = text;
// {@h} → "Hit: "
result = result.replace(/\{@h\}/g, "Hit: ");
result = result.replaceAll("{@h}", "Hit: ");
// {@hom} → "Hit or Miss: "
result = result.replace(/\{@hom\}/g, "Hit or Miss: ");
result = result.replaceAll("{@hom}", "Hit or Miss: ");
// {@actTrigger} → "Trigger:"
result = result.replace(/\{@actTrigger\}/g, "Trigger:");
result = result.replaceAll("{@actTrigger}", "Trigger:");
// {@actResponse} → "Response:"
result = result.replace(/\{@actResponse\}/g, "Response:");
result = result.replaceAll("{@actResponse}", "Response:");
// {@actSaveSuccess} → "Success:"
result = result.replace(/\{@actSaveSuccess\}/g, "Success:");
result = result.replaceAll("{@actSaveSuccess}", "Success:");
// {@actSaveSuccessOrFail} → handled below as parameterized
// {@recharge 5} → "(Recharge 5-6)", {@recharge} → "(Recharge 6)"
result = result.replace(/\{@recharge\s+(\d)\}/g, "(Recharge $1-6)");
result = result.replace(/\{@recharge\}/g, "(Recharge 6)");
result = result.replaceAll(/\{@recharge\s+(\d)\}/g, "(Recharge $1-6)");
result = result.replaceAll("{@recharge}", "(Recharge 6)");
// {@dc N} → "DC N"
result = result.replace(/\{@dc\s+(\d+)\}/g, "DC $1");
result = result.replaceAll(/\{@dc\s+(\d+)\}/g, "DC $1");
// {@hit N} → "+N"
result = result.replace(/\{@hit\s+(\d+)\}/g, "+$1");
result = result.replaceAll(/\{@hit\s+(\d+)\}/g, "+$1");
// {@atkr type} → mapped attack roll text
result = result.replace(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
return ATKR_MAP[type.trim()] ?? `Attack Roll:`;
// {@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.replace(/\{@actSave\s+([^}]+)\}/g, (_, ability: string) => {
const name = ABILITY_MAP[ability.trim().toLowerCase()];
return name ? `${name} saving throw` : `${ability} saving throw`;
});
result = result.replaceAll(
/\{@actSave\s+([^}]+)\}/g,
(_, ability: string) => {
const name = ABILITY_MAP[ability.trim().toLowerCase()];
return name ? `${name} saving throw` : `${ability} saving throw`;
},
);
// {@actSaveFail} → "Failure:" or {@actSaveFail N} → "Failure by N or More:"
result = result.replace(
result = result.replaceAll(
/\{@actSaveFail\s+(\d+)\}/g,
"Failure by $1 or More:",
);
result = result.replace(/\{@actSaveFail\}/g, "Failure:");
result = result.replaceAll("{@actSaveFail}", "Failure:");
// {@actSaveSuccessOrFail} → keep as-is label
result = result.replace(/\{@actSaveSuccessOrFail\}/g, "Success or Failure:");
result = result.replaceAll("{@actSaveSuccessOrFail}", "Success or Failure:");
// {@actSaveFailBy N} → "Failure by N or More:"
result = result.replace(
result = result.replaceAll(
/\{@actSaveFailBy\s+(\d+)\}/g,
"Failure by $1 or More:",
);
@@ -81,7 +98,7 @@ export function stripTags(text: string): string {
// Generic tags: {@tag Display|Source|...} → Display (first segment before |)
// Covers: spell, condition, damage, dice, variantrule, action, skill,
// creature, hazard, status, plus any unknown tags
result = result.replace(
result = result.replaceAll(
/\{@(\w+)\s+([^}]+)\}/g,
(_, tag: string, content: string) => {
// For tags with Display|Source format, extract first segment

View File

@@ -0,0 +1,121 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
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 { AllProviders } from "../../__tests__/test-providers.js";
import { ActionBar } from "../action-bar.js";
// Mock persistence — no localStorage interaction
vi.mock("../../persistence/encounter-storage.js", () => ({
loadEncounter: () => null,
saveEncounter: () => {},
}));
vi.mock("../../persistence/player-character-storage.js", () => ({
loadPlayerCharacters: () => [],
savePlayerCharacters: () => {},
}));
// Mock bestiary — no IndexedDB or JSON index
vi.mock("../../adapters/bestiary-cache.js", () => ({
loadAllCachedCreatures: () => Promise.resolve(new Map()),
isSourceCached: () => Promise.resolve(false),
cacheSource: () => Promise.resolve(),
getCachedSources: () => Promise.resolve([]),
clearSource: () => Promise.resolve(),
clearAll: () => Promise.resolve(),
}));
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
getAllSourceCodes: () => [],
getDefaultFetchUrl: () => "",
getSourceDisplayName: (code: string) => code,
}));
// DOM API stubs — jsdom doesn't implement these
beforeAll(() => {
Object.defineProperty(globalThis, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
afterEach(cleanup);
function renderBar(props: Partial<Parameters<typeof ActionBar>[0]> = {}) {
return render(<ActionBar {...props} />, { wrapper: AllProviders });
}
describe("ActionBar", () => {
it("renders input with placeholder '+ Add combatants'", () => {
renderBar();
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
});
it("submitting with a name adds a combatant", async () => {
const user = userEvent.setup();
renderBar();
const input = screen.getByPlaceholderText("+ Add combatants");
await user.type(input, "Goblin");
// The Add button appears when name >= 2 chars and no suggestions
const addButton = screen.getByRole("button", { name: "Add" });
await user.click(addButton);
// Input is cleared after adding (context handles the state)
expect(input).toHaveValue("");
});
it("submitting with empty name does nothing", async () => {
const user = userEvent.setup();
renderBar();
// Submit the form directly (Enter on empty input)
const input = screen.getByPlaceholderText("+ Add combatants");
await user.type(input, "{Enter}");
// Input stays empty, no error
expect(input).toHaveValue("");
});
it("shows custom fields (Init, AC, MaxHP) when name >= 2 chars and no bestiary suggestions", async () => {
const user = userEvent.setup();
renderBar();
const input = screen.getByPlaceholderText("+ Add combatants");
await user.type(input, "Go");
expect(screen.getByPlaceholderText("Init")).toBeInTheDocument();
expect(screen.getByPlaceholderText("AC")).toBeInTheDocument();
expect(screen.getByPlaceholderText("MaxHP")).toBeInTheDocument();
});
it("shows Add button when name >= 2 chars and no suggestions", async () => {
const user = userEvent.setup();
renderBar();
const input = screen.getByPlaceholderText("+ Add combatants");
await user.type(input, "Go");
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
});
it("does not show roll all initiative button when no creature combatants", () => {
renderBar();
expect(
screen.queryByRole("button", { name: "Roll all initiative" }),
).not.toBeInTheDocument();
});
it("shows overflow menu items", () => {
renderBar({ onManagePlayers: vi.fn() });
// The overflow menu should be present (it contains Player Characters etc.)
expect(
screen.getByRole("button", { name: "More actions" }),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,300 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { type CreatureId, combatantId } 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 { AllProviders } from "../../__tests__/test-providers.js";
import { CombatantRow } from "../combatant-row.js";
import { PLAYER_COLOR_HEX } from "../player-icon-map.js";
const TEMP_HP_REGEX = /^\+\d/;
// Mock persistence — no localStorage interaction
vi.mock("../../persistence/encounter-storage.js", () => ({
loadEncounter: () => null,
saveEncounter: () => {},
}));
vi.mock("../../persistence/player-character-storage.js", () => ({
loadPlayerCharacters: () => [],
savePlayerCharacters: () => {},
}));
// Mock bestiary — no IndexedDB or JSON index
vi.mock("../../adapters/bestiary-cache.js", () => ({
loadAllCachedCreatures: () => Promise.resolve(new Map()),
isSourceCached: () => Promise.resolve(false),
cacheSource: () => Promise.resolve(),
getCachedSources: () => Promise.resolve([]),
clearSource: () => Promise.resolve(),
clearAll: () => Promise.resolve(),
}));
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
getAllSourceCodes: () => [],
getDefaultFetchUrl: () => "",
getSourceDisplayName: (code: string) => code,
}));
// DOM API stubs
beforeAll(() => {
Object.defineProperty(globalThis, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
afterEach(cleanup);
function renderRow(
overrides: Partial<{
combatant: Parameters<typeof CombatantRow>[0]["combatant"];
isActive: boolean;
}> = {},
) {
const combatant = overrides.combatant ?? {
id: combatantId("1"),
name: "Goblin",
initiative: 15,
maxHp: 10,
currentHp: 10,
ac: 13,
};
return render(
<CombatantRow
combatant={combatant}
isActive={overrides.isActive ?? false}
/>,
{ wrapper: AllProviders },
);
}
describe("CombatantRow", () => {
it("renders combatant name", () => {
renderRow();
expect(screen.getByText("Goblin")).toBeInTheDocument();
});
it("renders initiative value", () => {
renderRow();
expect(screen.getByText("15")).toBeInTheDocument();
});
it("renders current HP", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
maxHp: 10,
currentHp: 7,
},
});
expect(screen.getByText("7")).toBeInTheDocument();
});
it("active combatant gets active border styling", () => {
const { container } = renderRow({ isActive: true });
const row = container.firstElementChild;
expect(row?.className).toContain("border-active-row-border");
});
it("unconscious combatant (currentHp === 0) gets dimmed styling", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
maxHp: 10,
currentHp: 0,
},
});
// The name area should have opacity-50
const nameEl = screen.getByText("Goblin");
const nameContainer = nameEl.closest(".opacity-50");
expect(nameContainer).not.toBeNull();
});
it("shows 'Max' placeholder when no maxHp is set", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
},
});
expect(screen.getByText("Max")).toBeInTheDocument();
});
it("shows concentration icon when isConcentrating is true", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
isConcentrating: true,
},
});
const concButton = screen.getByRole("button", {
name: "Toggle concentration",
});
expect(concButton.className).toContain("text-purple-400");
});
it("shows player character icon and color when set", () => {
const { container } = renderRow({
combatant: {
id: combatantId("1"),
name: "Aragorn",
color: "red",
icon: "sword",
},
});
// The icon should be rendered with the player color
const svgIcon = container.querySelector("svg[style]");
expect(svgIcon).not.toBeNull();
expect(svgIcon).toHaveStyle({ color: PLAYER_COLOR_HEX.red });
});
it("remove button removes after confirmation", async () => {
const user = userEvent.setup();
renderRow();
const removeBtn = screen.getByRole("button", {
name: "Remove combatant",
});
// First click enters confirm state
await user.click(removeBtn);
// Second click confirms
const confirmBtn = screen.getByRole("button", {
name: "Confirm remove combatant",
});
await user.click(confirmBtn);
// After confirming, the button returns to its initial state
expect(
screen.queryByRole("button", { name: "Confirm remove combatant" }),
).not.toBeInTheDocument();
});
it("shows d20 roll button when initiative is undefined and combatant has creatureId", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
creatureId: "srd:goblin" as CreatureId,
},
});
expect(
screen.getByRole("button", { name: "Roll initiative" }),
).toBeInTheDocument();
});
describe("concentration pulse", () => {
it("pulses when currentHp drops on a concentrating combatant", () => {
const combatant = {
id: combatantId("1"),
name: "Goblin",
maxHp: 20,
currentHp: 15,
isConcentrating: true,
};
const { rerender, container } = renderRow({ combatant });
rerender(
<CombatantRow
combatant={{ ...combatant, currentHp: 10 }}
isActive={false}
/>,
);
const row = container.firstElementChild;
expect(row?.className).toContain("animate-concentration-pulse");
});
it("does not pulse when not concentrating", () => {
const combatant = {
id: combatantId("1"),
name: "Goblin",
maxHp: 20,
currentHp: 15,
isConcentrating: false,
};
const { rerender, container } = renderRow({ combatant });
rerender(
<CombatantRow
combatant={{ ...combatant, currentHp: 10 }}
isActive={false}
/>,
);
const row = container.firstElementChild;
expect(row?.className).not.toContain("animate-concentration-pulse");
});
it("pulses when temp HP absorbs all damage on a concentrating combatant", () => {
const combatant = {
id: combatantId("1"),
name: "Goblin",
maxHp: 20,
currentHp: 15,
tempHp: 8,
isConcentrating: true,
};
const { rerender, container } = renderRow({ combatant });
// Temp HP absorbs all damage, currentHp unchanged
rerender(
<CombatantRow
combatant={{ ...combatant, tempHp: 3 }}
isActive={false}
/>,
);
const row = container.firstElementChild;
expect(row?.className).toContain("animate-concentration-pulse");
});
});
describe("temp HP display", () => {
it("shows +N when combatant has temp HP", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
maxHp: 20,
currentHp: 15,
tempHp: 5,
},
});
expect(screen.getByText("+5")).toBeInTheDocument();
});
it("does not show +N when combatant has no temp HP", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
maxHp: 20,
currentHp: 15,
},
});
expect(screen.queryByText(TEMP_HP_REGEX)).not.toBeInTheDocument();
});
it("temp HP display uses cyan color", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
maxHp: 20,
currentHp: 15,
tempHp: 8,
},
});
const tempHpEl = screen.getByText("+8");
expect(tempHpEl.className).toContain("text-cyan-400");
});
});
});

View File

@@ -0,0 +1,72 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
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);
function renderPicker(
overrides: Partial<{
activeConditions: readonly ConditionId[];
onToggle: (conditionId: ConditionId) => void;
onClose: () => void;
}> = {},
) {
const onToggle = overrides.onToggle ?? vi.fn();
const onClose = overrides.onClose ?? vi.fn();
const anchorRef = createRef<HTMLElement>() as RefObject<HTMLElement>;
const anchor = document.createElement("div");
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 };
}
describe("ConditionPicker", () => {
it("renders all condition definitions from domain", () => {
renderPicker();
for (const def of CONDITION_DEFINITIONS) {
expect(screen.getByText(def.label)).toBeInTheDocument();
}
});
it("active conditions are visually distinguished", () => {
renderPicker({ activeConditions: ["blinded"] });
const blindedButton = screen.getByText("Blinded").closest("button");
expect(blindedButton?.className).toContain("bg-card/50");
});
it("clicking a condition calls onToggle with that condition's ID", async () => {
const user = userEvent.setup();
const { onToggle } = renderPicker();
await user.click(screen.getByText("Poisoned"));
expect(onToggle).toHaveBeenCalledWith("poisoned");
});
it("non-active conditions render with muted styling", () => {
renderPicker({ activeConditions: [] });
const label = screen.getByText("Charmed");
expect(label.className).toContain("text-muted-foreground");
});
it("active condition labels use foreground color", () => {
renderPicker({ activeConditions: ["charmed"] });
const label = screen.getByText("Charmed");
expect(label.className).toContain("text-foreground");
});
});

View File

@@ -0,0 +1,148 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { HpAdjustPopover } from "../hp-adjust-popover";
afterEach(cleanup);
function renderPopover(
overrides: Partial<{
onAdjust: (delta: number) => void;
onSetTempHp: (value: number) => void;
onClose: () => void;
}> = {},
) {
const onAdjust = overrides.onAdjust ?? vi.fn();
const onSetTempHp = overrides.onSetTempHp ?? vi.fn();
const onClose = overrides.onClose ?? vi.fn();
const result = render(
<HpAdjustPopover
onAdjust={onAdjust}
onSetTempHp={onSetTempHp}
onClose={onClose}
/>,
);
return { ...result, onAdjust, onSetTempHp, onClose };
}
describe("HpAdjustPopover", () => {
it("renders input with placeholder 'HP'", () => {
renderPopover();
expect(screen.getByPlaceholderText("HP")).toBeInTheDocument();
});
it("damage and heal buttons are disabled when input is empty", () => {
renderPopover();
expect(screen.getByRole("button", { name: "Apply damage" })).toBeDisabled();
expect(
screen.getByRole("button", { name: "Apply healing" }),
).toBeDisabled();
});
it("damage and heal buttons are disabled when input is '0'", async () => {
const user = userEvent.setup();
renderPopover();
await user.type(screen.getByPlaceholderText("HP"), "0");
expect(screen.getByRole("button", { name: "Apply damage" })).toBeDisabled();
expect(
screen.getByRole("button", { name: "Apply healing" }),
).toBeDisabled();
});
it("typing a valid number enables both buttons", async () => {
const user = userEvent.setup();
renderPopover();
await user.type(screen.getByPlaceholderText("HP"), "5");
expect(
screen.getByRole("button", { name: "Apply damage" }),
).not.toBeDisabled();
expect(
screen.getByRole("button", { name: "Apply healing" }),
).not.toBeDisabled();
});
it("clicking damage button calls onAdjust with negative value and onClose", async () => {
const user = userEvent.setup();
const { onAdjust, onClose } = renderPopover();
await user.type(screen.getByPlaceholderText("HP"), "7");
await user.click(screen.getByRole("button", { name: "Apply damage" }));
expect(onAdjust).toHaveBeenCalledWith(-7);
expect(onClose).toHaveBeenCalled();
});
it("clicking heal button calls onAdjust with positive value and onClose", async () => {
const user = userEvent.setup();
const { onAdjust, onClose } = renderPopover();
await user.type(screen.getByPlaceholderText("HP"), "3");
await user.click(screen.getByRole("button", { name: "Apply healing" }));
expect(onAdjust).toHaveBeenCalledWith(3);
expect(onClose).toHaveBeenCalled();
});
it("Enter key applies damage (negative)", async () => {
const user = userEvent.setup();
const { onAdjust, onClose } = renderPopover();
const input = screen.getByPlaceholderText("HP");
await user.type(input, "4");
await user.keyboard("{Enter}");
expect(onAdjust).toHaveBeenCalledWith(-4);
expect(onClose).toHaveBeenCalled();
});
it("Shift+Enter applies healing (positive)", async () => {
const user = userEvent.setup();
const { onAdjust, onClose } = renderPopover();
const input = screen.getByPlaceholderText("HP");
await user.type(input, "6");
await user.keyboard("{Shift>}{Enter}{/Shift}");
expect(onAdjust).toHaveBeenCalledWith(6);
expect(onClose).toHaveBeenCalled();
});
it("Escape key calls onClose", async () => {
const user = userEvent.setup();
const { onClose } = renderPopover();
const input = screen.getByPlaceholderText("HP");
await user.type(input, "2");
await user.keyboard("{Escape}");
expect(onClose).toHaveBeenCalled();
});
it("only accepts digit characters in input", async () => {
const user = userEvent.setup();
renderPopover();
const input = screen.getByPlaceholderText("HP");
await user.type(input, "12abc34");
expect(input).toHaveValue("1234");
});
describe("temp HP", () => {
it("shield button calls onSetTempHp with entered value and closes", async () => {
const user = userEvent.setup();
const { onSetTempHp, onClose } = renderPopover();
await user.type(screen.getByPlaceholderText("HP"), "8");
await user.click(screen.getByRole("button", { name: "Set temp HP" }));
expect(onSetTempHp).toHaveBeenCalledWith(8);
expect(onClose).toHaveBeenCalled();
});
it("shield button is disabled when input is empty", () => {
renderPopover();
expect(
screen.getByRole("button", { name: "Set temp HP" }),
).toBeDisabled();
});
it("shield button is disabled when input is '0'", async () => {
const user = userEvent.setup();
renderPopover();
await user.type(screen.getByPlaceholderText("HP"), "0");
expect(
screen.getByRole("button", { name: "Set temp HP" }),
).toBeDisabled();
});
});
});

View File

@@ -0,0 +1,150 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("../../adapters/bestiary-cache.js", () => ({
getCachedSources: vi.fn(),
clearSource: vi.fn(),
clearAll: vi.fn(),
}));
// Mock the context module
vi.mock("../../contexts/bestiary-context.js", () => ({
useBestiaryContext: vi.fn(),
}));
import * as bestiaryCache from "../../adapters/bestiary-cache.js";
import { useBestiaryContext } from "../../contexts/bestiary-context.js";
import { SourceManager } from "../source-manager.js";
const mockGetCachedSources = vi.mocked(bestiaryCache.getCachedSources);
const mockClearSource = vi.mocked(bestiaryCache.clearSource);
const mockClearAll = vi.mocked(bestiaryCache.clearAll);
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
function setupMockContext() {
const refreshCache = vi.fn().mockResolvedValue(undefined);
mockUseBestiaryContext.mockReturnValue({
refreshCache,
search: vi.fn().mockReturnValue([]),
getCreature: vi.fn(),
isLoaded: true,
isSourceCached: vi.fn().mockResolvedValue(false),
fetchAndCacheSource: vi.fn(),
uploadAndCacheSource: vi.fn(),
} as ReturnType<typeof useBestiaryContext>);
return { refreshCache };
}
describe("SourceManager", () => {
it("shows 'No cached sources' empty state when no sources", async () => {
setupMockContext();
mockGetCachedSources.mockResolvedValue([]);
render(<SourceManager />);
await waitFor(() => {
expect(screen.getByText("No cached sources")).toBeInTheDocument();
});
});
it("lists cached sources with display name and creature count", async () => {
setupMockContext();
mockGetCachedSources.mockResolvedValue([
{
sourceCode: "mm",
displayName: "Monster Manual",
creatureCount: 300,
cachedAt: Date.now(),
},
{
sourceCode: "vgm",
displayName: "Volo's Guide",
creatureCount: 100,
cachedAt: Date.now(),
},
]);
render(<SourceManager />);
await waitFor(() => {
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
});
expect(screen.getByText("300 creatures")).toBeInTheDocument();
expect(screen.getByText("Volo's Guide")).toBeInTheDocument();
expect(screen.getByText("100 creatures")).toBeInTheDocument();
});
it("Clear All button calls cache clear and refreshCache", async () => {
const user = userEvent.setup();
const { refreshCache } = setupMockContext();
mockGetCachedSources
.mockResolvedValueOnce([
{
sourceCode: "mm",
displayName: "Monster Manual",
creatureCount: 300,
cachedAt: Date.now(),
},
])
.mockResolvedValue([]);
mockClearAll.mockResolvedValue(undefined);
render(<SourceManager />);
await waitFor(() => {
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "Clear All" }));
await waitFor(() => {
expect(mockClearAll).toHaveBeenCalled();
});
expect(refreshCache).toHaveBeenCalled();
});
it("individual source delete button calls clear for that source", async () => {
const user = userEvent.setup();
const { refreshCache } = setupMockContext();
mockGetCachedSources
.mockResolvedValueOnce([
{
sourceCode: "mm",
displayName: "Monster Manual",
creatureCount: 300,
cachedAt: Date.now(),
},
{
sourceCode: "vgm",
displayName: "Volo's Guide",
creatureCount: 100,
cachedAt: Date.now(),
},
])
.mockResolvedValue([
{
sourceCode: "vgm",
displayName: "Volo's Guide",
creatureCount: 100,
cachedAt: Date.now(),
},
]);
mockClearSource.mockResolvedValue(undefined);
render(<SourceManager />);
await waitFor(() => {
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
});
await user.click(
screen.getByRole("button", { name: "Remove Monster Manual" }),
);
await waitFor(() => {
expect(mockClearSource).toHaveBeenCalledWith("mm");
});
expect(refreshCache).toHaveBeenCalled();
});
});

View File

@@ -5,11 +5,23 @@ import type { Encounter } from "@initiative/domain";
import { combatantId } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TurnNavigation } from "../turn-navigation";
afterEach(cleanup);
// Mock the context module
vi.mock("../../contexts/encounter-context.js", () => ({
useEncounterContext: vi.fn(),
}));
function renderNav(overrides: Partial<Encounter> = {}) {
import { useEncounterContext } from "../../contexts/encounter-context.js";
import { TurnNavigation } from "../turn-navigation.js";
const mockUseEncounterContext = vi.mocked(useEncounterContext);
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
function mockContext(overrides: Partial<Encounter> = {}) {
const encounter: Encounter = {
combatants: [
{ id: combatantId("1"), name: "Goblin" },
@@ -20,16 +32,46 @@ function renderNav(overrides: Partial<Encounter> = {}) {
...overrides,
};
return render(
<TurnNavigation
encounter={encounter}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>,
const value = {
encounter,
advanceTurn: vi.fn(),
retreatTurn: vi.fn(),
clearEncounter: vi.fn(),
isEmpty: encounter.combatants.length === 0,
hasCreatureCombatants: false,
canRollAllInitiative: false,
addCombatant: vi.fn(),
removeCombatant: vi.fn(),
editCombatant: vi.fn(),
setInitiative: vi.fn(),
setHp: vi.fn(),
adjustHp: vi.fn(),
setTempHp: vi.fn(),
hasTempHp: false,
setAc: vi.fn(),
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,
events: [],
};
mockUseEncounterContext.mockReturnValue(
value as ReturnType<typeof useEncounterContext>,
);
return value;
}
function renderNav(overrides: Partial<Encounter> = {}) {
mockContext(overrides);
return render(<TurnNavigation />);
}
describe("TurnNavigation", () => {
@@ -51,88 +93,39 @@ describe("TurnNavigation", () => {
it("does not render an em dash between round and name", () => {
const { container } = renderNav();
expect(container.textContent).not.toContain("");
expect(container.textContent).not.toContain("\u2014");
});
it("round badge and combatant name are in separate DOM elements", () => {
it("round badge and combatant name are siblings in the center area", () => {
renderNav();
const badge = screen.getByText("R1");
const name = screen.getByText("Goblin");
expect(badge.parentElement).not.toBe(name.parentElement);
// badge text is inside inner span > outer span, name is a direct child
expect(badge.closest(".flex")).toBe(name.parentElement);
});
it("updates the round badge when round changes", () => {
const { rerender } = render(
<TurnNavigation
encounter={{
combatants: [{ id: combatantId("1"), name: "Goblin" }],
activeIndex: 0,
roundNumber: 2,
}}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>,
);
mockContext({ roundNumber: 2 });
const { rerender } = render(<TurnNavigation />);
expect(screen.getByText("R2")).toBeInTheDocument();
rerender(
<TurnNavigation
encounter={{
combatants: [{ id: combatantId("1"), name: "Goblin" }],
activeIndex: 0,
roundNumber: 3,
}}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>,
);
mockContext({ roundNumber: 3 });
rerender(<TurnNavigation />);
expect(screen.getByText("R3")).toBeInTheDocument();
expect(screen.queryByText("R2")).not.toBeInTheDocument();
});
it("renders the next combatant name when turn advances", () => {
const { rerender } = render(
<TurnNavigation
encounter={{
combatants: [
{ id: combatantId("1"), name: "Goblin" },
{ id: combatantId("2"), name: "Conjurer" },
],
activeIndex: 0,
roundNumber: 1,
}}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>,
);
const combatants = [
{ id: combatantId("1"), name: "Goblin" },
{ id: combatantId("2"), name: "Conjurer" },
];
mockContext({ combatants, activeIndex: 0 });
const { rerender } = render(<TurnNavigation />);
expect(screen.getByText("Goblin")).toBeInTheDocument();
rerender(
<TurnNavigation
encounter={{
combatants: [
{ id: combatantId("1"), name: "Goblin" },
{ id: combatantId("2"), name: "Conjurer" },
],
activeIndex: 1,
roundNumber: 1,
}}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>,
);
mockContext({ combatants, activeIndex: 1 });
rerender(<TurnNavigation />);
expect(screen.getByText("Conjurer")).toBeInTheDocument();
});
});
@@ -173,16 +166,6 @@ describe("TurnNavigation", () => {
expect(
screen.getByRole("button", { name: "Next turn" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", {
name: "Roll all initiative",
}),
).toBeInTheDocument();
expect(
screen.getByRole("button", {
name: "Manage cached sources",
}),
).toBeInTheDocument();
});
it("renders a 40-character name without truncation class issues", () => {

View File

@@ -12,25 +12,23 @@ export function AcShield({ value, onClick, className }: AcShieldProps) {
type="button"
onClick={onClick}
className={cn(
"relative inline-flex items-center justify-center text-sm tabular-nums text-muted-foreground transition-colors hover:text-hover-neutral",
"relative inline-flex items-center justify-center text-muted-foreground text-sm tabular-nums transition-colors hover:text-hover-neutral",
className,
)}
style={{ width: 28, height: 32 }}
>
<svg
viewBox="0 0 28 32"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="var(--color-border)"
fillOpacity={0.5}
stroke="none"
className="absolute inset-0 h-full w-full"
aria-hidden="true"
>
<path d="M14 1.5 L2.5 6.5 L2.5 15 Q2.5 25 14 30.5 Q25.5 25 25.5 15 L25.5 6.5 Z" />
</svg>
<span className="relative text-xs font-medium leading-none">
{value !== undefined ? value : "\u2014"}
<span className="relative -mt-0.5 font-medium text-xs leading-none">
{value == null ? "\u2014" : String(value)}
</span>
</button>
);

View File

@@ -1,416 +1,505 @@
import { Check, Eye, Import, Minus, Plus } from "lucide-react";
import { type FormEvent, useEffect, useRef, useState } from "react";
import type { SearchResult } from "../hooks/use-bestiary.js";
import type { PlayerCharacter } from "@initiative/domain";
import {
Check,
Eye,
EyeOff,
Import,
Library,
Minus,
Plus,
Settings,
Users,
} from "lucide-react";
import React, { type RefObject, useCallback, useState } from "react";
import type { SearchResult } 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 {
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 { D20Icon } from "./d20-icon.js";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
import { RollModeMenu } from "./roll-mode-menu.js";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
interface QueuedCreature {
result: SearchResult;
count: number;
}
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
interface ActionBarProps {
onAddCombatant: (
name: string,
opts?: { initiative?: number; ac?: number; maxHp?: number },
) => void;
onAddFromBestiary: (result: SearchResult) => void;
bestiarySearch: (query: string) => SearchResult[];
bestiaryLoaded: boolean;
onViewStatBlock?: (result: SearchResult) => void;
onBulkImport?: () => void;
bulkImportDisabled?: boolean;
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({
nameInput,
suggestions,
pcMatches,
suggestionIndex,
queued,
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={actions.dismiss}
>
<Plus className="h-3.5 w-3.5" />
<span className="flex-1">Add "{nameInput}" as custom</span>
<kbd className="rounded border border-border px-1.5 py-0.5 text-muted-foreground text-xs">
Esc
</kbd>
</button>
<div className="max-h-48 overflow-y-auto py-1">
{pcMatches.length > 0 && (
<>
<div className="px-3 py-1 font-medium text-muted-foreground text-xs">
Players
</div>
<ul>
{pcMatches.map((pc) => {
const PcIcon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
const pcColor = pc.color
? PLAYER_COLOR_HEX[pc.color]
: undefined;
return (
<li key={pc.id}>
<button
type="button"
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={() => {
actions.addFromPlayerCharacter?.(pc);
actions.clear();
}}
>
{!!PcIcon && (
<PcIcon size={14} style={{ color: pcColor }} />
)}
<span className="flex-1 truncate">{pc.name}</span>
<span className="text-muted-foreground text-xs">
Player
</span>
</button>
</li>
);
})}
</ul>
</>
)}
{suggestions.length > 0 && (
<ul>
{suggestions.map((result, i) => {
const key = creatureKey(result);
const isQueued =
queued !== null && creatureKey(queued.result) === key;
return (
<li key={key}>
<button
type="button"
className={cn(
"flex w-full items-center justify-between px-3 py-1.5 text-left text-foreground text-sm",
isQueued && "bg-accent/30",
!isQueued && i === suggestionIndex && "bg-accent/20",
!isQueued &&
i !== suggestionIndex &&
"hover:bg-hover-neutral-bg",
)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => actions.clickSuggestion(result)}
onMouseEnter={() => actions.setSuggestionIndex(i)}
>
<span>{result.name}</span>
<span className="flex items-center gap-1 text-muted-foreground text-xs">
{isQueued ? (
<>
<button
type="button"
className="rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
if (queued.count <= 1) {
actions.setQueued(null);
} else {
actions.setQueued({
...queued,
count: queued.count - 1,
});
}
}}
>
<Minus className="h-3 w-3" />
</button>
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground">
{queued.count}
</span>
<button
type="button"
className="rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
actions.setQueued({
...queued,
count: queued.count + 1,
});
}}
>
<Plus className="h-3 w-3" />
</button>
<button
type="button"
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
actions.confirmQueued();
}}
>
<Check className="h-3.5 w-3.5" />
</button>
</>
) : (
result.sourceDisplayName
)}
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
</div>
);
}
interface BrowseSuggestionsProps {
suggestions: SearchResult[];
suggestionIndex: number;
onSelect: (result: SearchResult) => void;
onHover: (index: number) => void;
}
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;
onOpenSourceManager?: () => void;
bestiaryLoaded: boolean;
onBulkImport?: () => void;
bulkImportDisabled?: boolean;
onOpenSettings?: () => void;
}): OverflowMenuItem[] {
const items: OverflowMenuItem[] = [];
if (opts.onManagePlayers) {
items.push({
icon: <Users className="h-4 w-4" />,
label: "Player Characters",
onClick: opts.onManagePlayers,
});
}
if (opts.onOpenSourceManager) {
items.push({
icon: <Library className="h-4 w-4" />,
label: "Manage Sources",
onClick: opts.onOpenSourceManager,
});
}
if (opts.bestiaryLoaded && opts.onBulkImport) {
items.push({
icon: <Import className="h-4 w-4" />,
label: "Import All Sources",
onClick: opts.onBulkImport,
disabled: opts.bulkImportDisabled,
});
}
if (opts.onOpenSettings) {
items.push({
icon: <Settings className="h-4 w-4" />,
label: "Settings",
onClick: opts.onOpenSettings,
});
}
return items;
}
export function ActionBar({
onAddCombatant,
onAddFromBestiary,
bestiarySearch,
bestiaryLoaded,
onViewStatBlock,
onBulkImport,
bulkImportDisabled,
}: ActionBarProps) {
const [nameInput, setNameInput] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
const [suggestionIndex, setSuggestionIndex] = useState(-1);
const [queued, setQueued] = useState<QueuedCreature | null>(null);
const [customInit, setCustomInit] = useState("");
const [customAc, setCustomAc] = useState("");
const [customMaxHp, setCustomMaxHp] = useState("");
inputRef,
autoFocus,
onManagePlayers,
onOpenSettings,
}: Readonly<ActionBarProps>) {
const {
nameInput,
suggestions,
pcMatches,
suggestionIndex,
queued,
customInit,
customAc,
customMaxHp,
browseMode,
bestiaryLoaded,
hasSuggestions,
showBulkImport,
showSourceManager,
suggestionActions,
handleNameChange,
handleKeyDown,
handleBrowseKeyDown,
handleAdd,
handleBrowseSelect,
toggleBrowseMode,
setCustomInit,
setCustomAc,
setCustomMaxHp,
} = useActionBarState();
// Stat block viewer: separate dropdown
const [viewerOpen, setViewerOpen] = useState(false);
const [viewerQuery, setViewerQuery] = useState("");
const [viewerResults, setViewerResults] = useState<SearchResult[]>([]);
const [viewerIndex, setViewerIndex] = useState(-1);
const viewerRef = useRef<HTMLDivElement>(null);
const viewerInputRef = useRef<HTMLInputElement>(null);
const { state: bulkImportState } = useBulkImportContext();
const clearCustomFields = () => {
setCustomInit("");
setCustomAc("");
setCustomMaxHp("");
};
const confirmQueued = () => {
if (!queued) return;
for (let i = 0; i < queued.count; i++) {
onAddFromBestiary(queued.result);
}
setQueued(null);
setNameInput("");
setSuggestions([]);
setSuggestionIndex(-1);
};
const parseNum = (v: string): number | undefined => {
if (v.trim() === "") return undefined;
const n = Number(v);
return Number.isNaN(n) ? undefined : n;
};
const handleAdd = (e: FormEvent) => {
e.preventDefault();
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;
onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
setNameInput("");
setSuggestions([]);
clearCustomFields();
};
const handleNameChange = (value: string) => {
setNameInput(value);
setSuggestionIndex(-1);
let newSuggestions: SearchResult[] = [];
if (value.length >= 2) {
newSuggestions = bestiarySearch(value);
setSuggestions(newSuggestions);
} else {
setSuggestions([]);
}
if (newSuggestions.length > 0) {
clearCustomFields();
}
if (queued) {
const qKey = creatureKey(queued.result);
const stillVisible = newSuggestions.some((s) => creatureKey(s) === qKey);
if (!stillVisible) {
setQueued(null);
}
}
};
const 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 handleKeyDown = (e: React.KeyboardEvent) => {
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") {
e.preventDefault();
handleEnter();
} else if (e.key === "Escape") {
setQueued(null);
setSuggestionIndex(-1);
setSuggestions([]);
}
};
// Stat block viewer dropdown handlers
const openViewer = () => {
setViewerOpen(true);
setViewerQuery("");
setViewerResults([]);
setViewerIndex(-1);
requestAnimationFrame(() => viewerInputRef.current?.focus());
};
const closeViewer = () => {
setViewerOpen(false);
setViewerQuery("");
setViewerResults([]);
setViewerIndex(-1);
};
const handleViewerQueryChange = (value: string) => {
setViewerQuery(value);
setViewerIndex(-1);
if (value.length >= 2) {
setViewerResults(bestiarySearch(value));
} else {
setViewerResults([]);
}
};
const handleViewerSelect = (result: SearchResult) => {
onViewStatBlock?.(result);
closeViewer();
};
const handleViewerKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
closeViewer();
return;
}
if (viewerResults.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setViewerIndex((i) => (i < viewerResults.length - 1 ? i + 1 : 0));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setViewerIndex((i) => (i > 0 ? i - 1 : viewerResults.length - 1));
} else if (e.key === "Enter" && viewerIndex >= 0) {
e.preventDefault();
handleViewerSelect(viewerResults[viewerIndex]);
}
};
// Close viewer on outside click
useEffect(() => {
if (!viewerOpen) return;
function handleClickOutside(e: MouseEvent) {
if (viewerRef.current && !viewerRef.current.contains(e.target as Node)) {
closeViewer();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [viewerOpen]);
const overflowItems = buildOverflowItems({
onManagePlayers,
onOpenSourceManager: showSourceManager,
bestiaryLoaded,
onBulkImport: showBulkImport,
bulkImportDisabled: bulkImportState.status === "loading",
onOpenSettings,
});
return (
<div className="flex items-center gap-3 rounded-md 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="relative flex-1">
<Input
type="text"
value={nameInput}
onChange={(e) => handleNameChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="+ Add combatants"
className="max-w-xs"
/>
{suggestions.length > 0 && (
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
<ul className="max-h-48 overflow-y-auto py-1">
{suggestions.map((result, i) => {
const key = creatureKey(result);
const isQueued =
queued !== null && creatureKey(queued.result) === key;
return (
<li key={key}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
isQueued
? "bg-accent/30 text-foreground"
: i === suggestionIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleClickSuggestion(result)}
onMouseEnter={() => setSuggestionIndex(i)}
>
<span>{result.name}</span>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{isQueued ? (
<>
<button
type="button"
className="rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
if (queued.count <= 1) {
setQueued(null);
} else {
setQueued({
...queued,
count: queued.count - 1,
});
}
}}
>
<Minus className="h-3 w-3" />
</button>
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-foreground">
{queued.count}
</span>
<button
type="button"
className="rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
setQueued({
...queued,
count: queued.count + 1,
});
}}
>
<Plus className="h-3 w-3" />
</button>
<button
type="button"
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
confirmQueued();
}}
>
<Check className="h-3.5 w-3.5" />
</button>
</>
) : (
result.sourceDisplayName
)}
</span>
</button>
</li>
);
})}
</ul>
</div>
)}
</div>
{nameInput.length >= 2 && suggestions.length === 0 && (
<div className="flex items-center gap-2">
<div className="flex-1">
<div className="relative max-w-xs">
<Input
ref={inputRef}
type="text"
inputMode="numeric"
value={customInit}
onChange={(e) => setCustomInit(e.target.value)}
placeholder="Init"
className="w-16 text-center"
value={nameInput}
onChange={(e) => handleNameChange(e.target.value)}
onKeyDown={browseMode ? handleBrowseKeyDown : handleKeyDown}
placeholder={
browseMode ? "Search stat blocks..." : "+ Add combatants"
}
className="pr-8"
autoFocus={autoFocus}
/>
<Input
type="text"
inputMode="numeric"
value={customAc}
onChange={(e) => setCustomAc(e.target.value)}
placeholder="AC"
className="w-16 text-center"
/>
<Input
type="text"
inputMode="numeric"
value={customMaxHp}
onChange={(e) => setCustomMaxHp(e.target.value)}
placeholder="MaxHP"
className="w-18 text-center"
/>
</div>
)}
<Button type="submit" size="sm">
Add
</Button>
{bestiaryLoaded && onViewStatBlock && (
<div ref={viewerRef} className="relative">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => (viewerOpen ? closeViewer() : openViewer())}
>
<Eye className="h-4 w-4" />
</Button>
{viewerOpen && (
<div className="absolute bottom-full right-0 z-50 mb-1 w-64 rounded-md border border-border bg-card shadow-lg">
<div className="p-2">
<Input
ref={viewerInputRef}
type="text"
value={viewerQuery}
onChange={(e) => handleViewerQueryChange(e.target.value)}
onKeyDown={handleViewerKeyDown}
placeholder="Search stat blocks..."
className="w-full"
/>
</div>
{viewerResults.length > 0 && (
<ul className="max-h-48 overflow-y-auto border-t border-border py-1">
{viewerResults.map((result, i) => (
<li key={creatureKey(result)}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
i === viewerIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
}`}
onClick={() => handleViewerSelect(result)}
onMouseEnter={() => setViewerIndex(i)}
>
<span>{result.name}</span>
<span className="text-xs text-muted-foreground">
{result.sourceDisplayName}
</span>
</button>
</li>
))}
</ul>
{!!bestiaryLoaded && (
<button
type="button"
tabIndex={-1}
className={cn(
"absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
browseMode && "text-accent",
)}
{viewerQuery.length >= 2 && viewerResults.length === 0 && (
<div className="border-t border-border px-3 py-2 text-sm text-muted-foreground">
No creatures found
</div>
onMouseDown={(e) => e.preventDefault()}
onClick={toggleBrowseMode}
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
aria-label={
browseMode ? "Switch to add mode" : "Browse stat blocks"
}
>
{browseMode ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</div>
</button>
)}
{!!browseMode && (
<BrowseSuggestions
suggestions={suggestions}
suggestionIndex={suggestionIndex}
onSelect={handleBrowseSelect}
onHover={suggestionActions.setSuggestionIndex}
/>
)}
{!browseMode && hasSuggestions && (
<AddModeSuggestions
nameInput={nameInput}
suggestions={suggestions}
pcMatches={pcMatches}
suggestionIndex={suggestionIndex}
queued={queued}
actions={suggestionActions}
/>
)}
</div>
</div>
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
<CustomStatFields
customInit={customInit}
customAc={customAc}
customMaxHp={customMaxHp}
onInitChange={setCustomInit}
onAcChange={setCustomAc}
onMaxHpChange={setCustomMaxHp}
/>
)}
{bestiaryLoaded && onBulkImport && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={onBulkImport}
disabled={bulkImportDisabled}
>
<Import className="h-4 w-4" />
</Button>
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
<Button type="submit">Add</Button>
)}
<RollAllButton />
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
</form>
</div>
);

View File

@@ -1,36 +1,41 @@
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { useId, useState } from "react";
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js";
import type { BulkImportState } from "../hooks/use-bulk-import.js";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
const DEFAULT_BASE_URL =
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
interface BulkImportPromptProps {
importState: BulkImportState;
onStartImport: (baseUrl: string) => void;
onDone: () => void;
}
export function BulkImportPrompt() {
const { fetchAndCacheSource, isSourceCached, refreshCache } =
useBestiaryContext();
const { state: importState, startImport, reset } = useBulkImportContext();
const { dismissPanel } = useSidePanelContext();
export function BulkImportPrompt({
importState,
onStartImport,
onDone,
}: BulkImportPromptProps) {
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
const baseUrlId = useId();
const totalSources = getAllSourceCodes().length;
const handleStart = (url: string) => {
startImport(url, fetchAndCacheSource, isSourceCached, refreshCache);
};
const handleDone = () => {
dismissPanel();
reset();
};
if (importState.status === "complete") {
return (
<div className="flex flex-col gap-4">
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-sm text-green-400">
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-green-400 text-sm">
All sources loaded
</div>
<Button size="sm" onClick={onDone}>
Done
</Button>
<Button onClick={handleDone}>Done</Button>
</div>
);
}
@@ -42,9 +47,7 @@ export function BulkImportPrompt({
Loaded {importState.completed}/{importState.total} sources (
{importState.failed} failed)
</div>
<Button size="sm" onClick={onDone}>
Done
</Button>
<Button onClick={handleDone}>Done</Button>
</div>
);
}
@@ -58,7 +61,7 @@ export function BulkImportPrompt({
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Loading sources... {processed}/{importState.total}
</div>
@@ -78,24 +81,20 @@ export function BulkImportPrompt({
return (
<div className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-foreground">
Bulk Import Sources
<h3 className="font-semibold text-foreground text-sm">
Import All Sources
</h3>
<p className="mt-1 text-xs text-muted-foreground">
Load stat block data for all {totalSources} sources at once. This will
download approximately 12.5 MB of data.
<p className="mt-1 text-muted-foreground text-xs">
Load stat block data for all {totalSources} sources at once.
</p>
</div>
<div className="flex flex-col gap-2">
<label
htmlFor="bulk-base-url"
className="text-xs text-muted-foreground"
>
<label htmlFor={baseUrlId} className="text-muted-foreground text-xs">
Base URL
</label>
<Input
id="bulk-base-url"
id={baseUrlId}
type="url"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
@@ -103,11 +102,7 @@ export function BulkImportPrompt({
/>
</div>
<Button
size="sm"
onClick={() => onStartImport(baseUrl)}
disabled={isDisabled}
>
<Button onClick={() => handleStart(baseUrl)} disabled={isDisabled}>
Load All
</Button>
</div>

View File

@@ -0,0 +1,44 @@
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js";
import { Toast } from "./toast.js";
export function BulkImportToasts() {
const { state, reset } = useBulkImportContext();
const { bulkImportMode, isRightPanelCollapsed } = useSidePanelContext();
const visible = !bulkImportMode || isRightPanelCollapsed;
if (!visible) return null;
if (state.status === "loading") {
return (
<Toast
message={`Loading sources... ${state.completed + state.failed}/${state.total}`}
progress={
state.total > 0 ? (state.completed + state.failed) / state.total : 0
}
onDismiss={() => {}}
/>
);
}
if (state.status === "complete") {
return (
<Toast
message="All sources loaded"
onDismiss={reset}
autoDismissMs={3000}
/>
);
}
if (state.status === "partial-failure") {
return (
<Toast
message={`Loaded ${state.completed}/${state.total} sources (${state.failed} failed)`}
onDismiss={reset}
/>
);
}
return null;
}

View File

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

View File

@@ -1,18 +1,27 @@
import {
type CombatantId,
type ConditionId,
type CreatureId,
deriveHpStatus,
type PlayerIcon,
type RollMode,
} from "@initiative/domain";
import { Brain, X } from "lucide-react";
import { Brain, Pencil, X } from "lucide-react";
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
import { cn } from "../lib/utils";
import { AcShield } from "./ac-shield";
import { ConditionPicker } from "./condition-picker";
import { ConditionTags } from "./condition-tags";
import { D20Icon } from "./d20-icon";
import { HpAdjustPopover } from "./hp-adjust-popover";
import { ConfirmButton } from "./ui/confirm-button";
import { Input } from "./ui/input";
import { useEncounterContext } from "../contexts/encounter-context.js";
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js";
import { useLongPress } from "../hooks/use-long-press.js";
import { cn } from "../lib/utils.js";
import { AcShield } from "./ac-shield.js";
import { ConditionPicker } from "./condition-picker.js";
import { ConditionTags } from "./condition-tags.js";
import { D20Icon } from "./d20-icon.js";
import { HpAdjustPopover } from "./hp-adjust-popover.js";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
import { RollModeMenu } from "./roll-mode-menu.js";
import { ConfirmButton } from "./ui/confirm-button.js";
import { Input } from "./ui/input.js";
interface Combatant {
readonly id: CombatantId;
@@ -20,43 +29,36 @@ interface Combatant {
readonly initiative?: number;
readonly maxHp?: number;
readonly currentHp?: number;
readonly tempHp?: number;
readonly ac?: number;
readonly conditions?: readonly ConditionId[];
readonly isConcentrating?: boolean;
readonly color?: string;
readonly icon?: string;
readonly creatureId?: CreatureId;
}
interface CombatantRowProps {
combatant: Combatant;
isActive: boolean;
onRename: (id: CombatantId, newName: string) => void;
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
onRemove: (id: CombatantId) => void;
onSetHp: (id: CombatantId, maxHp: number | undefined) => void;
onAdjustHp: (id: CombatantId, delta: number) => void;
onSetAc: (id: CombatantId, value: number | undefined) => void;
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
onToggleConcentration: (id: CombatantId) => void;
onShowStatBlock?: () => void;
onRollInitiative?: (id: CombatantId) => void;
}
function EditableName({
name,
combatantId,
onRename,
onShowStatBlock,
}: {
color,
onToggleStatBlock,
}: Readonly<{
name: string;
combatantId: CombatantId;
onRename: (id: CombatantId, newName: string) => void;
onShowStatBlock?: () => void;
}) {
color?: string;
onToggleStatBlock?: () => void;
}>) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(name);
const inputRef = useRef<HTMLInputElement>(null);
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const longPressTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const longPressTriggeredRef = useRef(false);
const commit = useCallback(() => {
const trimmed = draft.trim();
@@ -72,53 +74,13 @@ function EditableName({
requestAnimationFrame(() => inputRef.current?.select());
}, [name]);
useEffect(() => {
return () => {
clearTimeout(clickTimerRef.current);
clearTimeout(longPressTimerRef.current);
};
}, []);
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (longPressTriggeredRef.current) {
longPressTriggeredRef.current = false;
return;
}
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
clickTimerRef.current = undefined;
startEditing();
} else {
clickTimerRef.current = setTimeout(() => {
clickTimerRef.current = undefined;
onShowStatBlock?.();
}, 250);
}
},
[startEditing, onShowStatBlock],
);
const handleTouchStart = useCallback(() => {
longPressTriggeredRef.current = false;
longPressTimerRef.current = setTimeout(() => {
longPressTriggeredRef.current = true;
startEditing();
}, 500);
}, [startEditing]);
const cancelLongPress = useCallback(() => {
clearTimeout(longPressTimerRef.current);
}, []);
if (editing) {
return (
<Input
ref={inputRef}
type="text"
value={draft}
className="h-7 text-sm"
className="h-7 max-w-48 text-sm"
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
@@ -133,15 +95,27 @@ function EditableName({
<>
<button
type="button"
onClick={handleClick}
onTouchStart={handleTouchStart}
onTouchEnd={cancelLongPress}
onTouchCancel={cancelLongPress}
onTouchMove={cancelLongPress}
className="truncate text-left text-sm text-foreground cursor-text hover:text-hover-neutral transition-colors"
onClick={onToggleStatBlock}
disabled={!onToggleStatBlock}
className={cn(
"truncate text-left text-sm transition-colors",
onToggleStatBlock
? "cursor-pointer text-foreground hover:text-hover-neutral"
: "cursor-default text-foreground",
)}
style={color ? { color } : undefined}
>
{name}
</button>
<button
type="button"
onClick={startEditing}
title="Rename"
aria-label="Rename"
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>
</>
);
}
@@ -149,10 +123,10 @@ function EditableName({
function MaxHpDisplay({
maxHp,
onCommit,
}: {
}: Readonly<{
maxHp: number | undefined;
onCommit: (value: number | undefined) => void;
}) {
}>) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(maxHp?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null);
@@ -183,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) => {
@@ -198,7 +172,12 @@ function MaxHpDisplay({
<button
type="button"
onClick={startEditing}
className="inline-block h-7 min-w-[3ch] text-center text-sm leading-7 tabular-nums text-muted-foreground transition-colors hover:text-hover-neutral"
className={cn(
"inline-block h-7 min-w-[3ch] text-center leading-7 transition-colors hover:text-hover-neutral",
maxHp === undefined
? "text-muted-foreground text-sm"
: "text-muted-foreground text-xs",
)}
>
{maxHp ?? "Max"}
</button>
@@ -208,48 +187,47 @@ function MaxHpDisplay({
function ClickableHp({
currentHp,
maxHp,
tempHp,
onAdjust,
dimmed,
}: {
onSetTempHp,
}: Readonly<{
currentHp: number | undefined;
maxHp: number | undefined;
tempHp: number | undefined;
onAdjust: (delta: number) => void;
dimmed?: boolean;
}) {
onSetTempHp: (value: number) => void;
}>) {
const [popoverOpen, setPopoverOpen] = useState(false);
const status = deriveHpStatus(currentHp, maxHp);
if (maxHp === undefined) {
return (
<span
className={cn(
"inline-block h-7 w-[4ch] text-center text-sm leading-7 tabular-nums text-muted-foreground",
dimmed && "opacity-50",
)}
>
--
</span>
);
return null;
}
return (
<div className="relative">
<div className="relative flex items-center">
<button
type="button"
onClick={() => setPopoverOpen(true)}
aria-label={`Current HP: ${currentHp}${tempHp ? ` (+${tempHp} temp)` : ""} (${status})`}
className={cn(
"inline-block h-7 min-w-[3ch] text-center text-sm font-medium leading-7 tabular-nums transition-colors hover:text-hover-neutral",
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm leading-7 transition-colors hover:text-hover-neutral",
status === "bloodied" && "text-amber-400",
status === "unconscious" && "text-red-400",
status === "healthy" && "text-foreground",
dimmed && "opacity-50",
)}
>
{currentHp}
</button>
{popoverOpen && (
{!!tempHp && (
<span className="font-medium text-cyan-400 text-sm leading-7">
+{tempHp}
</span>
)}
{!!popoverOpen && (
<HpAdjustPopover
onAdjust={onAdjust}
onSetTempHp={onSetTempHp}
onClose={() => setPopoverOpen(false)}
/>
)}
@@ -260,10 +238,10 @@ function ClickableHp({
function AcDisplay({
ac,
onCommit,
}: {
}: Readonly<{
ac: number | undefined;
onCommit: (value: number | undefined) => void;
}) {
}>) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(ac?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null);
@@ -294,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) => {
@@ -314,16 +292,34 @@ function InitiativeDisplay({
dimmed,
onSetInitiative,
onRollInitiative,
}: {
}: Readonly<{
initiative: number | undefined;
combatantId: CombatantId;
dimmed: boolean;
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
onRollInitiative?: (id: CombatantId) => void;
}) {
onRollInitiative?: (id: CombatantId, mode?: RollMode) => void;
}>) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(initiative?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null);
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],
),
);
const commit = useCallback(() => {
if (draft === "") {
@@ -352,7 +348,7 @@ function InitiativeDisplay({
value={draft}
placeholder="--"
className={cn(
"h-7 w-[6ch] text-center text-sm tabular-nums",
"h-7 w-full text-center tabular-nums",
dimmed && "opacity-50",
)}
onChange={(e) => setDraft(e.target.value)}
@@ -365,35 +361,49 @@ function InitiativeDisplay({
);
}
// Empty + bestiary creature d20 roll button
// Empty + bestiary creature -> d20 roll button
if (initiative === undefined && onRollInitiative) {
return (
<button
type="button"
onClick={() => onRollInitiative(combatantId)}
className={cn(
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
dimmed && "opacity-50",
<>
<button
type="button"
onClick={() => onRollInitiative(combatantId)}
onContextMenu={(e) => {
e.preventDefault();
openMenu(e.clientX, e.clientY);
}}
{...longPress}
className={cn(
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
dimmed && "opacity-50",
)}
title="Roll initiative"
aria-label="Roll initiative"
>
<D20Icon className="h-7 w-7" />
</button>
{!!menuPos && (
<RollModeMenu
position={menuPos}
onSelect={(mode) => onRollInitiative(combatantId, mode)}
onClose={() => setMenuPos(null)}
/>
)}
title="Roll initiative"
aria-label="Roll initiative"
>
<D20Icon className="h-7 w-7" />
</button>
</>
);
}
// Has value bold number, click to edit
// Empty + manual "--" placeholder, click to edit
// Has value -> bold number, click to edit
// Empty + manual -> "--" placeholder, click to edit
return (
<button
type="button"
onClick={startEditing}
className={cn(
"h-7 w-full text-center text-sm leading-7 tabular-nums transition-colors",
initiative !== undefined
? "font-medium text-foreground hover:text-hover-neutral"
: "text-muted-foreground hover:text-hover-neutral",
"h-7 w-full text-center text-sm tabular-nums leading-7 transition-colors",
initiative === undefined
? "text-muted-foreground hover:text-hover-neutral"
: "font-medium text-foreground hover:text-hover-neutral",
dimmed && "opacity-50",
)}
>
@@ -406,9 +416,13 @@ function rowBorderClass(
isActive: boolean,
isConcentrating: boolean | undefined,
): string {
if (isActive) return "border-l-2 border-l-accent bg-accent/10";
if (isConcentrating) return "border-l-2 border-l-purple-400";
return "border-l-2 border-l-transparent";
if (isActive && isConcentrating)
return "border border-l-2 border-active-row-border border-l-purple-400 bg-active-row-bg card-glow";
if (isActive)
return "border border-l-2 border-active-row-border bg-active-row-bg card-glow";
if (isConcentrating)
return "border border-l-2 border-transparent border-l-purple-400";
return "border border-l-2 border-transparent";
}
function concentrationIconClass(
@@ -420,56 +434,71 @@ function concentrationIconClass(
return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400";
}
function activateOnKeyDown(
handler: () => void,
): (e: { key: string; preventDefault: () => void }) => void {
return (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handler();
}
};
}
export function CombatantRow({
ref,
combatant,
isActive,
onRename,
onSetInitiative,
onRemove,
onSetHp,
onAdjustHp,
onSetAc,
onToggleCondition,
onToggleConcentration,
onShowStatBlock,
onRollInitiative,
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
const {
editCombatant,
setInitiative,
removeCombatant,
setHp,
adjustHp,
setTempHp,
setAc,
toggleCondition,
toggleConcentration,
} = useEncounterContext();
const { selectedCreatureId, showCreature, toggleCollapse } =
useSidePanelContext();
const { handleRollInitiative } = useInitiativeRollsContext();
// Derive what was previously conditional props
const isStatBlockOpen = combatant.creatureId === selectedCreatureId;
const { creatureId } = combatant;
const hasStatBlock = !!creatureId;
const onToggleStatBlock = hasStatBlock
? () => {
if (isStatBlockOpen) {
toggleCollapse();
} else {
showCreature(creatureId);
}
}
: undefined;
const onRollInitiative = combatant.creatureId
? handleRollInitiative
: undefined;
const { id, name, initiative, maxHp, currentHp } = combatant;
const status = deriveHpStatus(currentHp, maxHp);
const dimmed = status === "unconscious";
const [pickerOpen, setPickerOpen] = useState(false);
const conditionAnchorRef = useRef<HTMLDivElement>(null);
const prevHpRef = useRef(currentHp);
const prevTempHpRef = useRef(combatant.tempHp);
const [isPulsing, setIsPulsing] = useState(false);
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
const prevHp = prevHpRef.current;
const prevTempHp = prevTempHpRef.current;
prevHpRef.current = currentHp;
prevTempHpRef.current = combatant.tempHp;
if (
prevHp !== undefined &&
currentHp !== undefined &&
currentHp < prevHp &&
combatant.isConcentrating
) {
const realHpDropped =
prevHp !== undefined && currentHp !== undefined && currentHp < prevHp;
const tempHpDropped =
prevTempHp !== undefined && (combatant.tempHp ?? 0) < prevTempHp;
if ((realHpDropped || tempHpDropped) && combatant.isConcentrating) {
setIsPulsing(true);
clearTimeout(pulseTimerRef.current);
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
}
}, [currentHp, combatant.isConcentrating]);
}, [currentHp, combatant.tempHp, combatant.isConcentrating]);
useEffect(() => {
if (!combatant.isConcentrating) {
@@ -478,35 +507,28 @@ export function CombatantRow({
}
}, [combatant.isConcentrating]);
const pcColor = combatant.color
? PLAYER_COLOR_HEX[combatant.color as keyof typeof PLAYER_COLOR_HEX]
: undefined;
return (
/* biome-ignore lint/a11y/noStaticElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
<div
ref={ref}
role={onShowStatBlock ? "button" : undefined}
tabIndex={onShowStatBlock ? 0 : undefined}
className={cn(
"group rounded-md pr-3 transition-colors",
"group rounded-lg pr-3 transition-colors",
rowBorderClass(isActive, combatant.isConcentrating),
isPulsing && "animate-concentration-pulse",
onShowStatBlock && "cursor-pointer",
)}
onClick={onShowStatBlock}
onKeyDown={
onShowStatBlock ? activateOnKeyDown(onShowStatBlock) : undefined
}
>
<div className="grid grid-cols-[2rem_3rem_1fr_auto_auto_2rem] items-center gap-3 py-2">
<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"
onClick={(e) => {
e.stopPropagation();
onToggleConcentration(id);
}}
onClick={() => toggleConcentration(id)}
title="Concentrating"
aria-label="Toggle concentration"
className={cn(
"flex w-full items-center justify-center self-stretch -my-2 -ml-[2px] pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
"-my-2 -ml-[2px] flex w-full items-center justify-center self-stretch pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
concentrationIconClass(combatant.isConcentrating, dimmed),
)}
>
@@ -514,91 +536,97 @@ export function CombatantRow({
</button>
{/* Initiative */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
<div
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<div className="rounded-md bg-muted/30 px-1">
<InitiativeDisplay
initiative={initiative}
combatantId={id}
dimmed={dimmed}
onSetInitiative={onSetInitiative}
onSetInitiative={setInitiative}
onRollInitiative={onRollInitiative}
/>
</div>
{/* AC */}
<div className={cn(dimmed && "opacity-50")}>
<AcDisplay ac={combatant.ac} onCommit={(v) => setAc(id, v)} />
</div>
{/* Name + Conditions */}
<div
className={cn(
"relative flex flex-wrap items-center gap-1 min-w-0",
"relative flex min-w-0 flex-wrap items-center gap-1",
dimmed && "opacity-50",
)}
>
{!!combatant.icon &&
!!combatant.color &&
(() => {
const PcIcon = PLAYER_ICON_MAP[combatant.icon as PlayerIcon];
const iconColor =
PLAYER_COLOR_HEX[
combatant.color as keyof typeof PLAYER_COLOR_HEX
];
return PcIcon ? (
<PcIcon
size={16}
style={{ color: iconColor }}
className="shrink-0"
/>
) : null;
})()}
<EditableName
name={name}
combatantId={id}
onRename={onRename}
onShowStatBlock={onShowStatBlock}
onRename={editCombatant}
color={pcColor}
onToggleStatBlock={onToggleStatBlock}
/>
<ConditionTags
conditions={combatant.conditions}
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
onOpenPicker={() => setPickerOpen((prev) => !prev)}
/>
{pickerOpen && (
<div ref={conditionAnchorRef}>
<ConditionTags
conditions={combatant.conditions}
onRemove={(conditionId) => toggleCondition(id, conditionId)}
onOpenPicker={() => setPickerOpen((prev) => !prev)}
/>
</div>
{!!pickerOpen && (
<ConditionPicker
anchorRef={conditionAnchorRef}
activeConditions={combatant.conditions}
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
onToggle={(conditionId) => toggleCondition(id, conditionId)}
onClose={() => setPickerOpen(false)}
/>
)}
</div>
{/* AC */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
<div
className={cn(dimmed && "opacity-50")}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
</div>
{/* HP */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
<div
className="flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
className={cn(
"flex items-center rounded-md tabular-nums",
maxHp === undefined
? ""
: "gap-0.5 border border-border/50 bg-muted/30 px-1.5",
dimmed && "opacity-50",
)}
>
<ClickableHp
currentHp={currentHp}
maxHp={maxHp}
onAdjust={(delta) => onAdjustHp(id, delta)}
dimmed={dimmed}
tempHp={combatant.tempHp}
onAdjust={(delta) => adjustHp(id, delta)}
onSetTempHp={(value) => setTempHp(id, value)}
/>
{maxHp !== undefined && (
<span
className={cn(
"text-sm tabular-nums text-muted-foreground",
dimmed && "opacity-50",
)}
>
/
</span>
<span className="text-muted-foreground/50 text-xs">/</span>
)}
<div className={cn(dimmed && "opacity-50")}>
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
</div>
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => setHp(id, v)} />
</div>
{/* Actions */}
<ConfirmButton
icon={<X size={16} />}
label="Remove combatant"
onConfirm={() => onRemove(id)}
className="h-7 w-7 text-muted-foreground opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus:opacity-100 focus:pointer-events-auto pointer-coarse:opacity-100 pointer-coarse:pointer-events-auto transition-opacity"
onConfirm={() => removeCombatant(id)}
className="pointer-events-none pointer-coarse:pointer-events-auto h-7 w-7 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-opacity focus:pointer-events-auto focus:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
/>
</div>
</div>

View File

@@ -1,4 +1,8 @@
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
import {
type ConditionId,
getConditionDescription,
getConditionsForEdition,
} from "@initiative/domain";
import type { LucideIcon } from "lucide-react";
import {
ArrowDown,
@@ -13,12 +17,17 @@ import {
Heart,
Link,
Moon,
ShieldMinus,
Siren,
Snail,
Sparkles,
ZapOff,
} from "lucide-react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
import { cn } from "../lib/utils";
import { Tooltip } from "./ui/tooltip.js";
const ICON_MAP: Record<string, LucideIcon> = {
EyeOff,
@@ -34,6 +43,8 @@ const ICON_MAP: Record<string, LucideIcon> = {
Droplet,
ArrowDown,
Link,
ShieldMinus,
Snail,
Sparkles,
Moon,
};
@@ -49,37 +60,49 @@ const COLOR_CLASSES: Record<string, string> = {
slate: "text-slate-400",
green: "text-green-400",
indigo: "text-indigo-400",
sky: "text-sky-400",
};
interface ConditionPickerProps {
anchorRef: React.RefObject<HTMLElement | null>;
activeConditions: readonly ConditionId[] | undefined;
onToggle: (conditionId: ConditionId) => void;
onClose: () => void;
}
export function ConditionPicker({
anchorRef,
activeConditions,
onToggle,
onClose,
}: ConditionPickerProps) {
}: Readonly<ConditionPickerProps>) {
const ref = useRef<HTMLDivElement>(null);
const [flipped, setFlipped] = useState(false);
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
const [pos, setPos] = useState<{
top: number;
left: number;
maxHeight: number;
} | null>(null);
useLayoutEffect(() => {
const anchor = anchorRef.current;
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.top;
const spaceAbove = rect.bottom;
const shouldFlip =
rect.bottom > window.innerHeight && spaceAbove > spaceBelow;
setFlipped(shouldFlip);
const available = shouldFlip ? spaceAbove : spaceBelow;
if (rect.height > available) {
setMaxHeight(available - 16);
}
}, []);
if (!anchor || !el) return;
const anchorRect = anchor.getBoundingClientRect();
const menuHeight = el.scrollHeight;
const pad = 8;
const spaceBelow = window.innerHeight - anchorRect.bottom - pad;
const spaceAbove = anchorRect.top - pad;
const openBelow = spaceBelow >= menuHeight || spaceBelow >= spaceAbove;
const top = openBelow
? anchorRect.bottom + 4
: Math.max(pad, anchorRect.top - Math.min(menuHeight, spaceAbove) - 4);
const maxHeight = openBelow ? spaceBelow : Math.min(menuHeight, spaceAbove);
setPos({ top, left: anchorRect.left, maxHeight });
}, [anchorRef]);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
@@ -91,44 +114,55 @@ export function ConditionPicker({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
const { edition } = useRulesEditionContext();
const conditions = getConditionsForEdition(edition);
const active = new Set(activeConditions ?? []);
return (
return createPortal(
<div
ref={ref}
className={cn(
"absolute left-0 z-10 w-fit overflow-y-auto rounded-md border border-border bg-background p-1 shadow-lg",
flipped ? "bottom-full mb-1" : "top-full mt-1",
)}
style={maxHeight ? { maxHeight } : undefined}
className="card-glow fixed z-50 w-fit overflow-y-auto rounded-lg border border-border bg-background p-1"
style={
pos
? { top: pos.top, left: pos.left, maxHeight: pos.maxHeight }
: { visibility: "hidden" as const }
}
>
{CONDITION_DEFINITIONS.map((def) => {
{conditions.map((def) => {
const Icon = ICON_MAP[def.iconName];
if (!Icon) return null;
const isActive = active.has(def.id);
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
return (
<button
<Tooltip
key={def.id}
type="button"
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
isActive && "bg-card/50",
)}
onClick={() => onToggle(def.id)}
content={getConditionDescription(def, edition)}
className="block"
>
<Icon
size={14}
className={isActive ? colorClass : "text-muted-foreground"}
/>
<span
className={isActive ? "text-foreground" : "text-muted-foreground"}
<button
type="button"
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
isActive && "bg-card/50",
)}
onClick={() => onToggle(def.id)}
>
{def.label}
</span>
</button>
<Icon
size={14}
className={isActive ? colorClass : "text-muted-foreground"}
/>
<span
className={
isActive ? "text-foreground" : "text-muted-foreground"
}
>
{def.label}
</span>
</button>
</Tooltip>
);
})}
</div>
</div>,
document.body,
);
}

View File

@@ -1,4 +1,8 @@
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
import {
CONDITION_DEFINITIONS,
type ConditionId,
getConditionDescription,
} from "@initiative/domain";
import type { LucideIcon } from "lucide-react";
import {
ArrowDown,
@@ -14,10 +18,15 @@ import {
Link,
Moon,
Plus,
ShieldMinus,
Siren,
Snail,
Sparkles,
ZapOff,
} from "lucide-react";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
import { cn } from "../lib/utils.js";
import { Tooltip } from "./ui/tooltip.js";
const ICON_MAP: Record<string, LucideIcon> = {
EyeOff,
@@ -33,6 +42,8 @@ const ICON_MAP: Record<string, LucideIcon> = {
Droplet,
ArrowDown,
Link,
ShieldMinus,
Snail,
Sparkles,
Moon,
};
@@ -48,6 +59,7 @@ const COLOR_CLASSES: Record<string, string> = {
slate: "text-slate-400",
green: "text-green-400",
indigo: "text-indigo-400",
sky: "text-sky-400",
};
interface ConditionTagsProps {
@@ -60,7 +72,8 @@ export function ConditionTags({
conditions,
onRemove,
onOpenPicker,
}: ConditionTagsProps) {
}: Readonly<ConditionTagsProps>) {
const { edition } = useRulesEditionContext();
return (
<div className="flex flex-wrap items-center gap-0.5">
{conditions?.map((condId) => {
@@ -70,26 +83,32 @@ export function ConditionTags({
if (!Icon) return null;
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
return (
<button
<Tooltip
key={condId}
type="button"
title={def.label}
aria-label={`Remove ${def.label}`}
className={`inline-flex items-center rounded p-0.5 hover:bg-hover-neutral-bg transition-colors ${colorClass}`}
onClick={(e) => {
e.stopPropagation();
onRemove(condId);
}}
content={`${def.label}:\n${getConditionDescription(def, edition)}`}
>
<Icon size={14} />
</button>
<button
type="button"
aria-label={`Remove ${def.label}`}
className={cn(
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
colorClass,
)}
onClick={(e) => {
e.stopPropagation();
onRemove(condId);
}}
>
<Icon size={14} />
</button>
</Tooltip>
);
})}
<button
type="button"
title="Add condition"
aria-label="Add condition"
className="inline-flex items-center rounded p-0.5 text-muted-foreground hover:text-hover-neutral hover:bg-hover-neutral-bg transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 pointer-coarse:opacity-100 transition-opacity"
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();

View File

@@ -0,0 +1,192 @@
import type { PlayerCharacter } from "@initiative/domain";
import { X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ColorPalette } from "./color-palette";
import { IconGrid } from "./icon-grid";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
interface CreatePlayerModalProps {
open: boolean;
onClose: () => void;
onSave: (
name: string,
ac: number,
maxHp: number,
color: string | undefined,
icon: string | undefined,
) => void;
playerCharacter?: PlayerCharacter;
}
export function CreatePlayerModal({
open,
onClose,
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 [error, setError] = useState("");
const isEdit = !!playerCharacter;
useEffect(() => {
if (open) {
if (playerCharacter) {
setName(playerCharacter.name);
setAc(String(playerCharacter.ac));
setMaxHp(String(playerCharacter.maxHp));
setColor(playerCharacter.color ?? "");
setIcon(playerCharacter.icon ?? "");
} else {
setName("");
setAc("10");
setMaxHp("10");
setColor("");
setIcon("");
}
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();
if (trimmed === "") {
setError("Name is required");
return;
}
const acNum = Number.parseInt(ac, 10);
if (Number.isNaN(acNum) || acNum < 0) {
setError("AC must be a non-negative number");
return;
}
const hpNum = Number.parseInt(maxHp, 10);
if (Number.isNaN(hpNum) || hpNum < 1) {
setError("Max HP must be at least 1");
return;
}
onSave(trimmed, acNum, hpNum, color || undefined, icon || undefined);
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">
{isEdit ? "Edit Player" : "Create Player"}
</h2>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="text-muted-foreground"
>
<X size={20} />
</Button>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<span className="mb-1 block text-muted-foreground text-sm">Name</span>
<Input
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
setError("");
}}
placeholder="Character name"
aria-label="Name"
autoFocus
/>
{!!error && <p className="mt-1 text-destructive text-sm">{error}</p>}
</div>
<div className="flex gap-3">
<div className="flex-1">
<span className="mb-1 block text-muted-foreground text-sm">AC</span>
<Input
type="text"
inputMode="numeric"
value={ac}
onChange={(e) => setAc(e.target.value)}
placeholder="AC"
aria-label="AC"
className="text-center"
/>
</div>
<div className="flex-1">
<span className="mb-1 block text-muted-foreground text-sm">
Max HP
</span>
<Input
type="text"
inputMode="numeric"
value={maxHp}
onChange={(e) => setMaxHp(e.target.value)}
placeholder="Max HP"
aria-label="Max HP"
className="text-center"
/>
</div>
</div>
<div>
<span className="mb-2 block text-muted-foreground text-sm">
Color
</span>
<ColorPalette value={color} onChange={setColor} />
</div>
<div>
<span className="mb-2 block text-muted-foreground text-sm">Icon</span>
<IconGrid value={icon} onChange={setIcon} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
</div>
</form>
</dialog>
);
}

View File

@@ -1,4 +1,4 @@
import { Heart, Sword } from "lucide-react";
import { Heart, ShieldPlus, Sword } from "lucide-react";
import {
useCallback,
useEffect,
@@ -6,15 +6,21 @@ import {
useRef,
useState,
} from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
const DIGITS_ONLY_REGEX = /^\d+$/;
interface HpAdjustPopoverProps {
readonly onAdjust: (delta: number) => void;
readonly onSetTempHp: (value: number) => void;
readonly onClose: () => void;
}
export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
export function HpAdjustPopover({
onAdjust,
onSetTempHp,
onClose,
}: HpAdjustPopoverProps) {
const [inputValue, setInputValue] = useState("");
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const ref = useRef<HTMLDivElement>(null);
@@ -86,7 +92,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
return (
<div
ref={ref}
className="fixed z-10 rounded-md border border-border bg-background p-2 shadow-lg"
className="card-glow fixed z-10 rounded-lg border border-border bg-background p-2"
style={
pos
? { top: pos.top, left: pos.left }
@@ -100,39 +106,50 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
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 === "" || /^\d+$/.test(v)) {
if (v === "" || DIGITS_ONLY_REGEX.test(v)) {
setInputValue(v);
}
}}
onKeyDown={handleKeyDown}
/>
<Button
<button
type="button"
variant="ghost"
size="icon"
disabled={!isValid}
className="h-7 w-7 shrink-0 text-red-400 hover:bg-red-950 hover:text-red-300"
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-red-400 transition-colors hover:bg-hp-damage-hover-bg hover:text-red-300 disabled:pointer-events-none disabled:opacity-50"
onClick={() => applyDelta(-1)}
title="Apply damage"
aria-label="Apply damage"
>
<Sword size={14} />
</Button>
<Button
</button>
<button
type="button"
variant="ghost"
size="icon"
disabled={!isValid}
className="h-7 w-7 shrink-0 text-emerald-400 hover:bg-emerald-950 hover:text-emerald-300"
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-emerald-400 transition-colors hover:bg-hp-heal-hover-bg hover:text-emerald-300 disabled:pointer-events-none disabled:opacity-50"
onClick={() => applyDelta(1)}
title="Apply healing"
aria-label="Apply healing"
>
<Heart size={14} />
</Button>
</button>
<button
type="button"
disabled={!isValid}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-cyan-400 transition-colors hover:bg-cyan-400/10 hover:text-cyan-300 disabled:pointer-events-none disabled:opacity-50"
onClick={() => {
if (isValid && parsedValue) {
onSetTempHp(parsedValue);
onClose();
}
}}
title="Set temp HP"
aria-label="Set temp HP"
>
<ShieldPlus size={14} />
</button>
</div>
</div>
);

View File

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

View File

@@ -0,0 +1,71 @@
import type { PlayerCharacter } from "@initiative/domain";
import { type RefObject, useImperativeHandle, useState } from "react";
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
import { CreatePlayerModal } from "./create-player-modal.js";
import { PlayerManagement } from "./player-management.js";
export interface PlayerCharacterSectionHandle {
openManagement: () => void;
}
export const PlayerCharacterSection = function PlayerCharacterSectionInner({
ref,
}: {
ref?: RefObject<PlayerCharacterSectionHandle | null>;
}) {
const { characters, createCharacter, editCharacter, deleteCharacter } =
usePlayerCharactersContext();
const [managementOpen, setManagementOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [editingPlayer, setEditingPlayer] = useState<
PlayerCharacter | undefined
>();
useImperativeHandle(ref, () => ({
openManagement: () => setManagementOpen(true),
}));
return (
<>
<CreatePlayerModal
open={createOpen}
onClose={() => {
setCreateOpen(false);
setEditingPlayer(undefined);
setManagementOpen(true);
}}
onSave={(name, ac, maxHp, color, icon) => {
if (editingPlayer) {
editCharacter(editingPlayer.id, {
name,
ac,
maxHp,
color: color ?? null,
icon: icon ?? null,
});
} else {
createCharacter(name, ac, maxHp, color, icon);
}
}}
playerCharacter={editingPlayer}
/>
<PlayerManagement
open={managementOpen}
onClose={() => setManagementOpen(false)}
characters={characters}
onEdit={(pc) => {
setEditingPlayer(pc);
setCreateOpen(true);
setManagementOpen(false);
}}
onDelete={(id) => deleteCharacter(id)}
onCreate={() => {
setEditingPlayer(undefined);
setCreateOpen(true);
setManagementOpen(false);
}}
/>
</>
);
};

View File

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

View File

@@ -0,0 +1,133 @@
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
import { Pencil, Plus, Trash2, X } from "lucide-react";
import { useEffect, useRef } from "react";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
import { Button } from "./ui/button";
import { ConfirmButton } from "./ui/confirm-button";
interface PlayerManagementProps {
open: boolean;
onClose: () => void;
characters: readonly PlayerCharacter[];
onEdit: (pc: PlayerCharacter) => void;
onDelete: (id: PlayerCharacterId) => void;
onCreate: () => void;
}
export function PlayerManagement({
open,
onClose,
characters,
onEdit,
onDelete,
onCreate,
}: 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>
{characters.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-8 text-center">
<p className="text-muted-foreground">No player characters yet</p>
<Button onClick={onCreate}>
<Plus size={16} />
Create your first player character
</Button>
</div>
) : (
<div className="flex flex-col gap-1">
{characters.map((pc) => {
const Icon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
const color = pc.color ? PLAYER_COLOR_HEX[pc.color] : undefined;
return (
<div
key={pc.id}
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-hover-neutral-bg"
>
{!!Icon && (
<Icon size={18} style={{ color }} className="shrink-0" />
)}
<span className="flex-1 truncate text-foreground text-sm">
{pc.name}
</span>
<span className="text-muted-foreground text-xs tabular-nums">
AC {pc.ac}
</span>
<span className="text-muted-foreground text-xs tabular-nums">
HP {pc.maxHp}
</span>
<Button
variant="ghost"
size="icon-sm"
onClick={() => onEdit(pc)}
className="text-muted-foreground"
title="Edit"
>
<Pencil size={14} />
</Button>
<ConfirmButton
icon={<Trash2 size={14} />}
label="Delete player character"
onConfirm={() => onDelete(pc.id)}
size="icon-sm"
className="text-muted-foreground"
/>
</div>
);
})}
<div className="mt-2 flex justify-end">
<Button onClick={onCreate} variant="ghost">
<Plus size={16} />
Add
</Button>
</div>
</div>
)}
</dialog>
);
}

View File

@@ -0,0 +1,88 @@
import type { RollMode } from "@initiative/domain";
import { ChevronsDown, ChevronsUp } from "lucide-react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
interface RollModeMenuProps {
readonly position: { x: number; y: number };
readonly onSelect: (mode: RollMode) => void;
readonly onClose: () => void;
}
export function RollModeMenu({
position,
onSelect,
onClose,
}: RollModeMenuProps) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
const vh = document.documentElement.clientHeight;
let left = position.x;
let top = position.y;
if (left + rect.width > vw) left = vw - rect.width - 8;
if (left < 8) left = 8;
if (top + rect.height > vh) top = position.y - rect.height;
if (top < 8) top = 8;
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]);
return (
<div
ref={ref}
className="card-glow fixed z-50 min-w-40 rounded-lg border border-border bg-card py-1"
style={
pos
? { top: pos.top, left: pos.left }
: { visibility: "hidden" as const }
}
>
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-emerald-400 text-sm hover:bg-hover-neutral-bg"
onClick={() => {
onSelect("advantage");
onClose();
}}
>
<ChevronsUp className="h-4 w-4" />
Advantage
</button>
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-red-400 text-sm hover:bg-hover-neutral-bg"
onClick={() => {
onSelect("disadvantage");
onClose();
}}
>
<ChevronsDown className="h-4 w-4" />
Disadvantage
</button>
</div>
);
}

View File

@@ -0,0 +1,129 @@
import type { RulesEdition } from "@initiative/domain";
import { Monitor, Moon, Sun, X } from "lucide-react";
import { useEffect, useRef } from "react";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
import { useThemeContext } from "../contexts/theme-context.js";
import { cn } from "../lib/utils.js";
import { Button } from "./ui/button.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 dialogRef = useRef<HTMLDialogElement>(null);
const { edition, setEdition } = useRulesEditionContext();
const { preference, setPreference } = useThemeContext();
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-sm 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">Settings</h2>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="text-muted-foreground"
>
<X size={20} />
</Button>
</div>
<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>
);
}

View File

@@ -1,28 +1,29 @@
import { Download, Loader2, Upload } from "lucide-react";
import { useRef, useState } from "react";
import { getDefaultFetchUrl } from "../adapters/bestiary-index-adapter.js";
import { useId, useRef, useState } from "react";
import {
getDefaultFetchUrl,
getSourceDisplayName,
} from "../adapters/bestiary-index-adapter.js";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
interface SourceFetchPromptProps {
sourceCode: string;
sourceDisplayName: string;
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
onSourceLoaded: () => void;
onUploadSource: (sourceCode: string, jsonData: unknown) => Promise<void>;
}
export function SourceFetchPrompt({
sourceCode,
sourceDisplayName,
fetchAndCacheSource,
onSourceLoaded,
onUploadSource,
}: SourceFetchPromptProps) {
}: Readonly<SourceFetchPromptProps>) {
const { fetchAndCacheSource, uploadAndCacheSource } = useBestiaryContext();
const sourceDisplayName = getSourceDisplayName(sourceCode);
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
const [error, setError] = useState<string>("");
const fileInputRef = useRef<HTMLInputElement>(null);
const sourceUrlId = useId();
const handleFetch = async () => {
setStatus("fetching");
@@ -46,7 +47,7 @@ export function SourceFetchPrompt({
try {
const text = await file.text();
const json = JSON.parse(text);
await onUploadSource(sourceCode, json);
await uploadAndCacheSource(sourceCode, json);
onSourceLoaded();
} catch (err) {
setStatus("error");
@@ -64,21 +65,21 @@ export function SourceFetchPrompt({
return (
<div className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-foreground">
<h3 className="font-semibold text-foreground text-sm">
Load {sourceDisplayName}
</h3>
<p className="mt-1 text-xs text-muted-foreground">
<p className="mt-1 text-muted-foreground text-xs">
Stat block data for this source needs to be loaded. Enter a URL or
upload a JSON file.
</p>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="source-url" className="text-xs text-muted-foreground">
<label htmlFor={sourceUrlId} className="text-muted-foreground text-xs">
Source URL
</label>
<Input
id="source-url"
id={sourceUrlId}
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
@@ -88,11 +89,7 @@ export function SourceFetchPrompt({
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={handleFetch}
disabled={status === "fetching" || !url}
>
<Button onClick={handleFetch} disabled={status === "fetching" || !url}>
{status === "fetching" ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
@@ -101,10 +98,9 @@ export function SourceFetchPrompt({
{status === "fetching" ? "Loading..." : "Load"}
</Button>
<span className="text-xs text-muted-foreground">or</span>
<span className="text-muted-foreground text-xs">or</span>
<Button
size="sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={status === "fetching"}
@@ -122,7 +118,7 @@ export function SourceFetchPrompt({
</div>
{status === "error" && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-destructive text-xs">
{error}
</div>
)}

View File

@@ -1,15 +1,31 @@
import { Database, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Database, Search, Trash2 } from "lucide-react";
import {
useCallback,
useEffect,
useMemo,
useOptimistic,
useState,
} from "react";
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
import * as bestiaryCache from "../adapters/bestiary-cache.js";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
interface SourceManagerProps {
onCacheCleared: () => void;
}
export function SourceManager({ onCacheCleared }: SourceManagerProps) {
export function SourceManager() {
const { refreshCache } = useBestiaryContext();
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
const [filter, setFilter] = useState("");
const [optimisticSources, applyOptimistic] = useOptimistic(
sources,
(
state,
action: { type: "remove"; sourceCode: string } | { type: "clear" },
) =>
action.type === "clear"
? []
: state.filter((s) => s.sourceCode !== action.sourceCode),
);
const loadSources = useCallback(async () => {
const cached = await bestiaryCache.getCachedSources();
@@ -17,26 +33,37 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
}, []);
useEffect(() => {
loadSources();
void loadSources();
}, [loadSources]);
const handleClearSource = async (sourceCode: string) => {
applyOptimistic({ type: "remove", sourceCode });
await bestiaryCache.clearSource(sourceCode);
await loadSources();
onCacheCleared();
void refreshCache();
};
const handleClearAll = async () => {
applyOptimistic({ type: "clear" });
await bestiaryCache.clearAll();
await loadSources();
onCacheCleared();
void refreshCache();
};
if (sources.length === 0) {
const filteredSources = useMemo(() => {
const term = filter.toLowerCase();
return term
? optimisticSources.filter((s) =>
s.displayName.toLowerCase().includes(term),
)
: optimisticSources;
}, [optimisticSources, filter]);
if (optimisticSources.length === 0) {
return (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<Database className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No cached sources</p>
<p className="text-muted-foreground text-sm">No cached sources</p>
</div>
);
}
@@ -44,32 +71,46 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-foreground">
<span className="font-semibold text-foreground text-sm">
Cached Sources
</span>
<Button size="sm" variant="outline" onClick={handleClearAll}>
<Button
variant="outline"
className="hover:border-hover-destructive hover:text-hover-destructive"
onClick={handleClearAll}
>
<Trash2 className="mr-1 h-3 w-3" />
Clear All
</Button>
</div>
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Filter sources…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="pl-8"
/>
</div>
<ul className="flex flex-col gap-1">
{sources.map((source) => (
{filteredSources.map((source) => (
<li
key={source.sourceCode}
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
>
<div>
<span className="text-sm text-foreground">
<span className="text-foreground text-sm">
{source.displayName}
</span>
<span className="ml-2 text-xs text-muted-foreground">
<span className="ml-2 text-muted-foreground text-xs">
{source.creatureCount} creatures
</span>
</div>
<button
type="button"
onClick={() => handleClearSource(source.sourceCode)}
className="text-muted-foreground hover:text-hover-danger"
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-hover-destructive-bg hover:text-hover-destructive"
aria-label={`Remove ${source.displayName}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>

View File

@@ -1,36 +1,20 @@
import type { Creature, CreatureId } from "@initiative/domain";
import type { CreatureId } from "@initiative/domain";
import { PanelRightClose, Pin, PinOff } from "lucide-react";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
import type { BulkImportState } from "../hooks/use-bulk-import.js";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js";
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
import { cn } from "../lib/utils.js";
import { BulkImportPrompt } from "./bulk-import-prompt.js";
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
import { SourceManager } from "./source-manager.js";
import { StatBlock } from "./stat-block.js";
import { Button } from "./ui/button.js";
interface StatBlockPanelProps {
creatureId: CreatureId | null;
creature: Creature | null;
isSourceCached: (sourceCode: string) => Promise<boolean>;
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
uploadAndCacheSource: (
sourceCode: string,
jsonData: unknown,
) => Promise<void>;
refreshCache: () => Promise<void>;
panelRole: "browse" | "pinned";
isFolded: boolean;
onToggleFold: () => void;
onPin: () => void;
onUnpin: () => void;
showPinButton: boolean;
side: "left" | "right";
onDismiss: () => void;
bulkImportMode?: boolean;
bulkImportState?: BulkImportState;
onStartBulkImport?: (baseUrl: string) => void;
onBulkImportDone?: () => void;
}
function extractSourceCode(cId: CreatureId): string {
@@ -39,25 +23,26 @@ function extractSourceCode(cId: CreatureId): string {
return cId.slice(0, colonIndex).toUpperCase();
}
function FoldedTab({
function CollapsedTab({
creatureName,
side,
onToggleFold,
}: {
onToggleCollapse,
}: Readonly<{
creatureName: string;
side: "left" | "right";
onToggleFold: () => void;
}) {
onToggleCollapse: () => void;
}>) {
return (
<button
type="button"
onClick={onToggleFold}
className={`flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral ${
side === "right" ? "self-start" : "self-end"
}`}
aria-label="Unfold stat block panel"
onClick={onToggleCollapse}
className={cn(
"flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral",
side === "right" ? "self-start" : "self-end",
)}
aria-label="Expand stat block panel"
>
<span className="writing-vertical-rl text-sm font-medium">
<span className="writing-vertical-rl font-medium text-sm">
{creatureName}
</span>
</button>
@@ -67,50 +52,53 @@ function FoldedTab({
function PanelHeader({
panelRole,
showPinButton,
onToggleFold,
onToggleCollapse,
onPin,
onUnpin,
}: {
}: Readonly<{
panelRole: "browse" | "pinned";
showPinButton: boolean;
onToggleFold: () => void;
onToggleCollapse: () => void;
onPin: () => void;
onUnpin: () => void;
}) {
}>) {
return (
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex items-center justify-between border-border border-b px-4 py-2">
<div className="flex items-center gap-1">
{panelRole === "browse" && (
<button
type="button"
onClick={onToggleFold}
className="text-muted-foreground hover:text-hover-neutral"
aria-label="Fold stat block panel"
<Button
variant="ghost"
size="icon-sm"
onClick={onToggleCollapse}
className="text-muted-foreground"
aria-label="Collapse stat block panel"
>
<PanelRightClose className="h-4 w-4" />
</button>
</Button>
)}
</div>
<div className="flex items-center gap-1">
{panelRole === "browse" && showPinButton && (
<button
type="button"
<Button
variant="ghost"
size="icon-sm"
onClick={onPin}
className="text-muted-foreground hover:text-hover-neutral"
className="text-muted-foreground"
aria-label="Pin creature"
>
<Pin className="h-4 w-4" />
</button>
</Button>
)}
{panelRole === "pinned" && (
<button
type="button"
<Button
variant="ghost"
size="icon-sm"
onClick={onUnpin}
className="text-muted-foreground hover:text-hover-neutral"
className="text-muted-foreground"
aria-label="Unpin creature"
>
<PinOff className="h-4 w-4" />
</button>
</Button>
)}
</div>
</div>
@@ -118,48 +106,52 @@ function PanelHeader({
}
function DesktopPanel({
isFolded,
isCollapsed,
side,
creatureName,
panelRole,
showPinButton,
onToggleFold,
onToggleCollapse,
onPin,
onUnpin,
children,
}: {
isFolded: boolean;
}: Readonly<{
isCollapsed: boolean;
side: "left" | "right";
creatureName: string;
panelRole: "browse" | "pinned";
showPinButton: boolean;
onToggleFold: () => void;
onToggleCollapse: () => void;
onPin: () => void;
onUnpin: () => void;
children: ReactNode;
}) {
}>) {
const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l";
const foldedTranslate =
const collapsedTranslate =
side === "right"
? "translate-x-[calc(100%-40px)]"
: "translate-x-[calc(-100%+40px)]";
return (
<div
className={`fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel ${sideClasses} ${isFolded ? foldedTranslate : "translate-x-0"}`}
className={cn(
"panel-glow fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel",
sideClasses,
isCollapsed ? collapsedTranslate : "translate-x-0",
)}
>
{isFolded ? (
<FoldedTab
{isCollapsed ? (
<CollapsedTab
creatureName={creatureName}
side={side}
onToggleFold={onToggleFold}
onToggleCollapse={onToggleCollapse}
/>
) : (
<>
<PanelHeader
panelRole={panelRole}
showPinButton={showPinButton}
onToggleFold={onToggleFold}
onToggleCollapse={onToggleCollapse}
onPin={onPin}
onUnpin={onUnpin}
/>
@@ -173,36 +165,40 @@ function DesktopPanel({
function MobileDrawer({
onDismiss,
children,
}: {
}: Readonly<{
onDismiss: () => void;
children: ReactNode;
}) {
}>) {
const { offsetX, isSwiping, handlers } = useSwipeToDismiss(onDismiss);
return (
<div className="fixed inset-0 z-50">
<button
type="button"
className="absolute inset-0 bg-black/50 animate-in fade-in"
className="fade-in absolute inset-0 animate-in bg-black/50"
onClick={onDismiss}
aria-label="Close stat block"
/>
<div
className={`absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-l border-border bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`}
className={cn(
"panel-glow absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-border border-l bg-card",
!isSwiping && "animate-slide-in-right",
)}
style={
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
}
{...handlers}
>
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<button
type="button"
<div className="flex items-center justify-between border-border border-b px-4 py-2">
<Button
variant="ghost"
size="icon-sm"
onClick={onDismiss}
className="text-muted-foreground hover:text-hover-neutral"
aria-label="Fold stat block panel"
className="text-muted-foreground"
aria-label="Collapse stat block panel"
>
<PanelRightClose className="h-4 w-4" />
</button>
</Button>
</div>
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
{children}
@@ -212,34 +208,57 @@ function MobileDrawer({
);
}
function usePanelRole(panelRole: "browse" | "pinned") {
const sidePanel = useSidePanelContext();
const { getCreature } = useBestiaryContext();
const creatureId =
panelRole === "browse"
? sidePanel.selectedCreatureId
: sidePanel.pinnedCreatureId;
const creature = creatureId ? (getCreature(creatureId) ?? null) : null;
const isBrowse = panelRole === "browse";
return {
creatureId,
creature,
isCollapsed: isBrowse ? sidePanel.isRightPanelCollapsed : false,
onToggleCollapse: isBrowse ? sidePanel.toggleCollapse : () => {},
onDismiss: isBrowse ? sidePanel.dismissPanel : () => {},
onPin: isBrowse ? sidePanel.togglePin : () => {},
onUnpin: panelRole === "pinned" ? sidePanel.unpin : () => {},
showPinButton: isBrowse && sidePanel.isWideDesktop && !!creature,
bulkImportMode: isBrowse && sidePanel.bulkImportMode,
sourceManagerMode: isBrowse && sidePanel.sourceManagerMode,
};
}
export function StatBlockPanel({
creatureId,
creature,
isSourceCached,
fetchAndCacheSource,
uploadAndCacheSource,
refreshCache,
panelRole,
isFolded,
onToggleFold,
onPin,
onUnpin,
showPinButton,
side,
onDismiss,
bulkImportMode,
bulkImportState,
onStartBulkImport,
onBulkImportDone,
}: StatBlockPanelProps) {
}: Readonly<StatBlockPanelProps>) {
const { isSourceCached } = useBestiaryContext();
const {
creatureId,
creature,
isCollapsed,
onToggleCollapse,
onDismiss,
onPin,
onUnpin,
showPinButton,
bulkImportMode,
sourceManagerMode,
} = usePanelRole(panelRole);
const [isDesktop, setIsDesktop] = useState(
() => window.matchMedia("(min-width: 1024px)").matches,
() => globalThis.matchMedia("(min-width: 1024px)").matches,
);
const [needsFetch, setNeedsFetch] = useState(false);
const [checkingCache, setCheckingCache] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(min-width: 1024px)");
const mq = globalThis.matchMedia("(min-width: 1024px)");
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
@@ -258,40 +277,32 @@ export function StatBlockPanel({
}
setCheckingCache(true);
isSourceCached(sourceCode).then((cached) => {
void isSourceCached(sourceCode).then((cached) => {
setNeedsFetch(!cached);
setCheckingCache(false);
});
}, [creatureId, creature, isSourceCached]);
if (!creatureId && !bulkImportMode) return null;
if (!creatureId && !bulkImportMode && !sourceManagerMode) return null;
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
const handleSourceLoaded = async () => {
await refreshCache();
const handleSourceLoaded = () => {
setNeedsFetch(false);
};
const renderContent = () => {
if (
bulkImportMode &&
bulkImportState &&
onStartBulkImport &&
onBulkImportDone
) {
return (
<BulkImportPrompt
importState={bulkImportState}
onStartImport={onStartBulkImport}
onDone={onBulkImportDone}
/>
);
if (sourceManagerMode) {
return <SourceManager />;
}
if (bulkImportMode) {
return <BulkImportPrompt />;
}
if (checkingCache) {
return (
<div className="p-4 text-sm text-muted-foreground">Loading...</div>
<div className="p-4 text-muted-foreground text-sm">Loading...</div>
);
}
@@ -303,33 +314,32 @@ export function StatBlockPanel({
return (
<SourceFetchPrompt
sourceCode={sourceCode}
sourceDisplayName={getSourceDisplayName(sourceCode)}
fetchAndCacheSource={fetchAndCacheSource}
onSourceLoaded={handleSourceLoaded}
onUploadSource={uploadAndCacheSource}
/>
);
}
return (
<div className="p-4 text-sm text-muted-foreground">
<div className="p-4 text-muted-foreground text-sm">
No stat block available
</div>
);
};
const creatureName =
creature?.name ?? (bulkImportMode ? "Bulk Import" : "Creature");
let fallbackName = "Creature";
if (sourceManagerMode) fallbackName = "Sources";
else if (bulkImportMode) fallbackName = "Import All Sources";
const creatureName = creature?.name ?? fallbackName;
if (isDesktop) {
return (
<DesktopPanel
isFolded={isFolded}
isCollapsed={isCollapsed}
side={side}
creatureName={creatureName}
panelRole={panelRole}
showPinButton={showPinButton}
onToggleFold={onToggleFold}
onToggleCollapse={onToggleCollapse}
onPin={onPin}
onUnpin={onUnpin}
>
@@ -338,7 +348,7 @@ export function StatBlockPanel({
);
}
if (panelRole === "pinned") return null;
if (panelRole === "pinned" || isCollapsed) return null;
return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
}

View File

@@ -16,10 +16,10 @@ function abilityMod(score: number): string {
function PropertyLine({
label,
value,
}: {
}: Readonly<{
label: string;
value: string | undefined;
}) {
}>) {
if (!value) return null;
return (
<div className="text-sm">
@@ -30,11 +30,11 @@ function PropertyLine({
function SectionDivider() {
return (
<div className="my-2 h-px bg-gradient-to-r from-amber-800/60 via-amber-700/40 to-transparent" />
<div className="my-2 h-px bg-gradient-to-r from-stat-divider-from via-stat-divider-via to-transparent" />
);
}
export function StatBlock({ creature }: StatBlockProps) {
export function StatBlock({ creature }: Readonly<StatBlockProps>) {
const abilities = [
{ label: "STR", score: creature.abilities.str },
{ label: "DEX", score: creature.abilities.dex },
@@ -54,11 +54,11 @@ export function StatBlock({ creature }: StatBlockProps) {
<div className="space-y-1 text-foreground">
{/* Header */}
<div>
<h2 className="text-xl font-bold text-amber-400">{creature.name}</h2>
<p className="text-sm italic text-muted-foreground">
<h2 className="font-bold text-stat-heading text-xl">{creature.name}</h2>
<p className="text-muted-foreground text-sm italic">
{creature.size} {creature.type}, {creature.alignment}
</p>
<p className="text-xs text-muted-foreground">
<p className="text-muted-foreground text-xs">
{creature.sourceDisplayName}
</p>
</div>
@@ -69,7 +69,7 @@ export function StatBlock({ creature }: StatBlockProps) {
<div className="space-y-0.5 text-sm">
<div>
<span className="font-semibold">Armor Class</span> {creature.ac}
{creature.acSource && (
{!!creature.acSource && (
<span className="text-muted-foreground">
{" "}
({creature.acSource})
@@ -194,7 +194,7 @@ export function StatBlock({ creature }: StatBlockProps) {
{creature.actions && creature.actions.length > 0 && (
<>
<SectionDivider />
<h3 className="text-base font-bold text-amber-400">Actions</h3>
<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">
@@ -209,7 +209,9 @@ export function StatBlock({ creature }: StatBlockProps) {
{creature.bonusActions && creature.bonusActions.length > 0 && (
<>
<SectionDivider />
<h3 className="text-base font-bold text-amber-400">Bonus Actions</h3>
<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">
@@ -224,7 +226,7 @@ export function StatBlock({ creature }: StatBlockProps) {
{creature.reactions && creature.reactions.length > 0 && (
<>
<SectionDivider />
<h3 className="text-base font-bold text-amber-400">Reactions</h3>
<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">
@@ -236,13 +238,13 @@ export function StatBlock({ creature }: StatBlockProps) {
)}
{/* Legendary Actions */}
{creature.legendaryActions && (
{!!creature.legendaryActions && (
<>
<SectionDivider />
<h3 className="text-base font-bold text-amber-400">
<h3 className="font-bold text-base text-stat-heading">
Legendary Actions
</h3>
<p className="text-sm italic text-muted-foreground">
<p className="text-muted-foreground text-sm italic">
{creature.legendaryActions.preamble}
</p>
<div className="space-y-2">

View File

@@ -1,6 +1,7 @@
import { X } from "lucide-react";
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { Button } from "./ui/button.js";
interface ToastProps {
message: string;
@@ -22,9 +23,9 @@ export function Toast({
}, [autoDismissMs, onDismiss]);
return createPortal(
<div className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2">
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg">
<span className="text-sm text-foreground">{message}</span>
<div className="fixed bottom-4 left-4 z-50">
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
<span className="text-foreground text-sm">{message}</span>
{progress !== undefined && (
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
<div
@@ -33,13 +34,14 @@ export function Toast({
/>
</div>
)}
<button
type="button"
<Button
variant="ghost"
size="icon-sm"
onClick={onDismiss}
className="text-muted-foreground hover:text-hover-neutral"
className="text-muted-foreground"
>
<X className="h-3 w-3" />
</button>
</Button>
</div>
</div>,
document.body,

View File

@@ -1,94 +1,85 @@
import type { Encounter } from "@initiative/domain";
import { Settings, StepBack, StepForward, Trash2 } from "lucide-react";
import { D20Icon } from "./d20-icon";
import { Button } from "./ui/button";
import { ConfirmButton } from "./ui/confirm-button";
import { Redo2, StepBack, StepForward, Trash2, Undo2 } from "lucide-react";
import { useEncounterContext } from "../contexts/encounter-context.js";
import { Button } from "./ui/button.js";
import { ConfirmButton } from "./ui/confirm-button.js";
interface TurnNavigationProps {
encounter: Encounter;
onAdvanceTurn: () => void;
onRetreatTurn: () => void;
onClearEncounter: () => void;
onRollAllInitiative: () => void;
onOpenSourceManager: () => void;
}
export function TurnNavigation() {
const {
encounter,
advanceTurn,
retreatTurn,
clearEncounter,
undo,
redo,
canUndo,
canRedo,
} = useEncounterContext();
export function TurnNavigation({
encounter,
onAdvanceTurn,
onRetreatTurn,
onClearEncounter,
onRollAllInitiative,
onOpenSourceManager,
}: TurnNavigationProps) {
const hasCombatants = encounter.combatants.length > 0;
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
const activeCombatant = encounter.combatants[encounter.activeIndex];
return (
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
<div className="flex flex-shrink-0 items-center gap-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"
onClick={retreatTurn}
disabled={!hasCombatants || isAtStart}
title="Previous turn"
aria-label="Previous turn"
>
<StepBack className="h-5 w-5" />
</Button>
<div className="flex items-center gap-1">
<Button
variant="outline"
variant="ghost"
size="icon"
className="h-8 w-8 text-foreground border-foreground hover:text-hover-action hover:border-hover-action hover:bg-transparent"
onClick={onRetreatTurn}
disabled={!hasCombatants || isAtStart}
title="Previous turn"
aria-label="Previous turn"
onClick={undo}
disabled={!canUndo}
title="Undo"
aria-label="Undo"
>
<StepBack className="h-5 w-5" />
<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>
<span className="rounded-full bg-muted text-foreground text-sm px-2 py-0.5 font-semibold">
R{encounter.roundNumber}
</span>
</div>
<div className="min-w-0 flex-1 text-center text-sm">
{activeCombatant ? (
<span className="truncate block font-medium">
{activeCombatant.name}
<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">
R{encounter.roundNumber}
</span>
</span>
{activeCombatant ? (
<span className="truncate font-medium">{activeCombatant.name}</span>
) : (
<span className="text-muted-foreground">No combatants</span>
)}
</div>
<div className="flex flex-shrink-0 items-center gap-3">
<div className="flex items-center gap-0">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-hover-action"
onClick={onRollAllInitiative}
title="Roll all initiative"
aria-label="Roll all initiative"
>
<D20Icon className="h-6 w-6" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-hover-neutral"
onClick={onOpenSourceManager}
title="Manage cached sources"
aria-label="Manage cached sources"
>
<Settings className="h-5 w-5" />
</Button>
<ConfirmButton
icon={<Trash2 className="h-5 w-5" />}
label="Clear encounter"
onConfirm={onClearEncounter}
disabled={!hasCombatants}
className="h-8 w-8 text-muted-foreground"
/>
</div>
<ConfirmButton
icon={<Trash2 className="h-5 w-5" />}
label="Clear encounter"
onConfirm={clearEncounter}
disabled={!hasCombatants}
className="text-muted-foreground"
/>
<Button
variant="outline"
variant="ghost"
size="icon"
className="h-8 w-8 text-foreground border-foreground hover:text-hover-action hover:border-hover-action hover:bg-transparent"
onClick={onAdvanceTurn}
onClick={advanceTurn}
disabled={!hasCombatants}
title="Next turn"
aria-label="Next turn"

View File

@@ -9,13 +9,14 @@ const buttonVariants = cva(
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
outline:
"border border-border bg-transparent hover:bg-hover-neutral-bg hover:text-hover-neutral",
ghost: "hover:bg-hover-neutral-bg hover:text-hover-neutral",
"border border-border bg-background/50 text-foreground hover:bg-hover-neutral-bg hover:text-hover-neutral",
ghost:
"text-foreground hover:bg-hover-neutral-bg hover:text-hover-neutral",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 px-3 text-xs",
default: "h-8 px-3 text-xs",
icon: "h-8 w-8",
"icon-sm": "h-6 w-6",
},
},
defaultVariants: {

View File

@@ -13,6 +13,7 @@ interface ConfirmButtonProps {
readonly onConfirm: () => void;
readonly icon: ReactElement;
readonly label: string;
readonly size?: "icon" | "icon-sm";
readonly className?: string;
readonly disabled?: boolean;
}
@@ -23,6 +24,7 @@ export function ConfirmButton({
onConfirm,
icon,
label,
size = "icon",
className,
disabled,
}: ConfirmButtonProps) {
@@ -53,17 +55,17 @@ export function ConfirmButton({
}
}
function handleKeyDown(e: KeyboardEvent) {
function handleEscapeKey(e: KeyboardEvent) {
if (e.key === "Escape") {
revert();
}
}
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("keydown", handleEscapeKey);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("keydown", handleEscapeKey);
};
}, [isConfirming, revert]);
@@ -94,11 +96,12 @@ export function ConfirmButton({
<div ref={wrapperRef} className="inline-flex">
<Button
variant="ghost"
size="icon"
size={size}
className={cn(
className,
isConfirming &&
"bg-destructive text-primary-foreground rounded-md animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground",
isConfirming
? "animate-confirm-pulse rounded-md bg-destructive text-primary-foreground hover:bg-destructive hover:text-primary-foreground"
: "hover:text-hover-destructive",
)}
onClick={handleClick}
onKeyDown={handleKeyDown}
@@ -107,7 +110,8 @@ export function ConfirmButton({
aria-label={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
title={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
>
{isConfirming ? <Check size={16} /> : icon}
{isConfirming ? <Check size={16} /> : null}
{!isConfirming && icon}
</Button>
</div>
);

View File

@@ -1,19 +1,21 @@
import { forwardRef, type InputHTMLAttributes } from "react";
import type { InputHTMLAttributes, RefObject } from "react";
import { cn } from "../../lib/utils";
type InputProps = InputHTMLAttributes<HTMLInputElement>;
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, ...props }, ref) => {
return (
<input
ref={ref}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm 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",
className,
)}
{...props}
/>
);
},
);
export const Input = ({
className,
ref,
...props
}: InputProps & { ref?: RefObject<HTMLInputElement | null> }) => {
return (
<input
ref={ref}
className={cn(
"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}
/>
);
};

View File

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

View File

@@ -0,0 +1,55 @@
import { type ReactNode, useRef, useState } from "react";
import { createPortal } from "react-dom";
interface TooltipProps {
content: string;
children: ReactNode;
className?: string;
}
export function Tooltip({
content,
children,
className,
}: Readonly<TooltipProps>) {
const ref = useRef<HTMLSpanElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
function show() {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setPos({
top: rect.top - 4,
left: rect.left + rect.width / 2,
});
}
function hide() {
setPos(null);
}
return (
<>
<span
ref={ref}
onPointerEnter={show}
onPointerLeave={hide}
className={className ?? "inline-flex"}
>
{children}
</span>
{pos !== null &&
createPortal(
<div
role="tooltip"
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}
</div>,
document.body,
)}
</>
);
}

View File

@@ -0,0 +1,23 @@
import { createContext, type ReactNode, useContext } from "react";
import { useBestiary } from "../hooks/use-bestiary.js";
export type { SearchResult } from "../hooks/use-bestiary.js";
type BestiaryContextValue = ReturnType<typeof useBestiary>;
const BestiaryContext = createContext<BestiaryContextValue | null>(null);
export function BestiaryProvider({ children }: { children: ReactNode }) {
const value = useBestiary();
return (
<BestiaryContext.Provider value={value}>
{children}
</BestiaryContext.Provider>
);
}
export function useBestiaryContext(): BestiaryContextValue {
const ctx = useContext(BestiaryContext);
if (!ctx) throw new Error("useBestiaryContext requires BestiaryProvider");
return ctx;
}

View File

@@ -0,0 +1,21 @@
import { createContext, type ReactNode, useContext } from "react";
import { useBulkImport } from "../hooks/use-bulk-import.js";
type BulkImportContextValue = ReturnType<typeof useBulkImport>;
const BulkImportContext = createContext<BulkImportContextValue | null>(null);
export function BulkImportProvider({ children }: { children: ReactNode }) {
const value = useBulkImport();
return (
<BulkImportContext.Provider value={value}>
{children}
</BulkImportContext.Provider>
);
}
export function useBulkImportContext(): BulkImportContextValue {
const ctx = useContext(BulkImportContext);
if (!ctx) throw new Error("useBulkImportContext requires BulkImportProvider");
return ctx;
}

View File

@@ -0,0 +1,23 @@
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>;
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}
</EncounterContext.Provider>
);
}
export function useEncounterContext(): EncounterContextValue {
const ctx = useContext(EncounterContext);
if (!ctx) throw new Error("useEncounterContext requires EncounterProvider");
return ctx;
}

View File

@@ -0,0 +1,8 @@
export { BestiaryProvider } from "./bestiary-context.js";
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";

View File

@@ -0,0 +1,25 @@
import { createContext, type ReactNode, useContext } from "react";
import { useInitiativeRolls } from "../hooks/use-initiative-rolls.js";
type InitiativeRollsContextValue = ReturnType<typeof useInitiativeRolls>;
const InitiativeRollsContext =
createContext<InitiativeRollsContextValue | null>(null);
export function InitiativeRollsProvider({ children }: { children: ReactNode }) {
const value = useInitiativeRolls();
return (
<InitiativeRollsContext.Provider value={value}>
{children}
</InitiativeRollsContext.Provider>
);
}
export function useInitiativeRollsContext(): InitiativeRollsContextValue {
const ctx = useContext(InitiativeRollsContext);
if (!ctx)
throw new Error(
"useInitiativeRollsContext requires InitiativeRollsProvider",
);
return ctx;
}

View File

@@ -0,0 +1,29 @@
import { createContext, type ReactNode, useContext } from "react";
import { usePlayerCharacters } from "../hooks/use-player-characters.js";
type PlayerCharactersContextValue = ReturnType<typeof usePlayerCharacters>;
const PlayerCharactersContext =
createContext<PlayerCharactersContextValue | null>(null);
export function PlayerCharactersProvider({
children,
}: {
children: ReactNode;
}) {
const value = usePlayerCharacters();
return (
<PlayerCharactersContext.Provider value={value}>
{children}
</PlayerCharactersContext.Provider>
);
}
export function usePlayerCharactersContext(): PlayerCharactersContextValue {
const ctx = useContext(PlayerCharactersContext);
if (!ctx)
throw new Error(
"usePlayerCharactersContext requires PlayerCharactersProvider",
);
return ctx;
}

View File

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

View File

@@ -0,0 +1,21 @@
import { createContext, type ReactNode, useContext } from "react";
import { useSidePanelState } from "../hooks/use-side-panel-state.js";
type SidePanelContextValue = ReturnType<typeof useSidePanelState>;
const SidePanelContext = createContext<SidePanelContextValue | null>(null);
export function SidePanelProvider({ children }: { children: ReactNode }) {
const value = useSidePanelState();
return (
<SidePanelContext.Provider value={value}>
{children}
</SidePanelContext.Provider>
);
}
export function useSidePanelContext(): SidePanelContextValue {
const ctx = useContext(SidePanelContext);
if (!ctx) throw new Error("useSidePanelContext requires SidePanelProvider");
return ctx;
}

View File

@@ -0,0 +1,19 @@
import { createContext, type ReactNode, useContext } from "react";
import { useTheme } from "../hooks/use-theme.js";
type ThemeContextValue = ReturnType<typeof useTheme>;
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const value = useTheme();
return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
}
export function useThemeContext(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useThemeContext requires ThemeProvider");
return ctx;
}

View File

@@ -0,0 +1,225 @@
// @vitest-environment jsdom
import type { BestiaryIndexEntry, PlayerCharacter } from "@initiative/domain";
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useEncounter } from "../use-encounter.js";
vi.mock("../../persistence/encounter-storage.js", () => ({
loadEncounter: vi.fn().mockReturnValue(null),
saveEncounter: vi.fn(),
}));
const { loadEncounter: mockLoad, saveEncounter: mockSave } =
await vi.importMock<typeof import("../../persistence/encounter-storage.js")>(
"../../persistence/encounter-storage.js",
);
describe("useEncounter", () => {
beforeEach(() => {
vi.clearAllMocks();
mockLoad.mockReturnValue(null);
});
it("initializes with empty encounter when persistence returns null", () => {
const { result } = renderHook(() => useEncounter());
expect(result.current.encounter.combatants).toEqual([]);
expect(result.current.encounter.activeIndex).toBe(0);
expect(result.current.encounter.roundNumber).toBe(1);
expect(result.current.isEmpty).toBe(true);
});
it("initializes from stored encounter", () => {
const stored = {
combatants: [{ id: combatantId("c-1"), name: "Goblin" }],
activeIndex: 0,
roundNumber: 2,
};
mockLoad.mockReturnValue(stored);
const { result } = renderHook(() => useEncounter());
expect(result.current.encounter.combatants).toHaveLength(1);
expect(result.current.encounter.roundNumber).toBe(2);
expect(result.current.isEmpty).toBe(false);
});
it("addCombatant adds a combatant with incremental IDs and persists", () => {
const { result } = renderHook(() => useEncounter());
act(() => result.current.addCombatant("Goblin"));
act(() => result.current.addCombatant("Orc"));
expect(result.current.encounter.combatants).toHaveLength(2);
expect(result.current.encounter.combatants[0].name).toBe("Goblin");
expect(result.current.encounter.combatants[1].name).toBe("Orc");
expect(result.current.isEmpty).toBe(false);
expect(mockSave).toHaveBeenCalled();
});
it("removeCombatant removes a combatant and persists", () => {
const { result } = renderHook(() => useEncounter());
act(() => result.current.addCombatant("Goblin"));
const id = result.current.encounter.combatants[0].id;
act(() => result.current.removeCombatant(id));
expect(result.current.encounter.combatants).toHaveLength(0);
expect(result.current.isEmpty).toBe(true);
});
it("advanceTurn and retreatTurn update encounter state", () => {
const { result } = renderHook(() => useEncounter());
act(() => result.current.addCombatant("Goblin"));
act(() => result.current.addCombatant("Orc"));
const initialActive = result.current.encounter.activeIndex;
act(() => result.current.advanceTurn());
expect(result.current.encounter.activeIndex).not.toBe(initialActive);
act(() => result.current.retreatTurn());
expect(result.current.encounter.activeIndex).toBe(initialActive);
});
it("clearEncounter resets to empty and resets ID counter", () => {
const { result } = renderHook(() => useEncounter());
act(() => result.current.addCombatant("Goblin"));
act(() => result.current.clearEncounter());
expect(result.current.encounter.combatants).toHaveLength(0);
expect(result.current.isEmpty).toBe(true);
// After clear, IDs restart from c-1
act(() => result.current.addCombatant("Orc"));
expect(result.current.encounter.combatants[0].id).toBe("c-1");
});
it("addCombatant with opts applies initiative, ac, maxHp", () => {
const { result } = renderHook(() => useEncounter());
act(() =>
result.current.addCombatant("Goblin", {
initiative: 15,
ac: 13,
maxHp: 7,
}),
);
const goblin = result.current.encounter.combatants[0];
expect(goblin.initiative).toBe(15);
expect(goblin.ac).toBe(13);
expect(goblin.maxHp).toBe(7);
expect(goblin.currentHp).toBe(7);
});
it("derived flags: hasCreatureCombatants and canRollAllInitiative", () => {
const { result } = renderHook(() => useEncounter());
// No creatures yet
expect(result.current.hasCreatureCombatants).toBe(false);
expect(result.current.canRollAllInitiative).toBe(false);
// Add from bestiary to get a creature combatant
const entry: BestiaryIndexEntry = {
name: "Goblin",
source: "MM",
ac: 15,
hp: 7,
dex: 14,
cr: "1/4",
initiativeProficiency: 0,
size: "Small",
type: "humanoid",
};
act(() => {
result.current.addFromBestiary(entry);
});
expect(result.current.hasCreatureCombatants).toBe(true);
expect(result.current.canRollAllInitiative).toBe(true);
});
it("addFromBestiary adds combatant with HP, AC, creatureId", () => {
const { result } = renderHook(() => useEncounter());
const entry: BestiaryIndexEntry = {
name: "Goblin",
source: "MM",
ac: 15,
hp: 7,
dex: 14,
cr: "1/4",
initiativeProficiency: 0,
size: "Small",
type: "humanoid",
};
act(() => {
result.current.addFromBestiary(entry);
});
const combatant = result.current.encounter.combatants[0];
expect(combatant.name).toBe("Goblin");
expect(combatant.maxHp).toBe(7);
expect(combatant.currentHp).toBe(7);
expect(combatant.ac).toBe(15);
expect(combatant.creatureId).toBe(creatureId("mm:goblin"));
});
it("addFromBestiary auto-numbers duplicate names", () => {
const { result } = renderHook(() => useEncounter());
const entry: BestiaryIndexEntry = {
name: "Goblin",
source: "MM",
ac: 15,
hp: 7,
dex: 14,
cr: "1/4",
initiativeProficiency: 0,
size: "Small",
type: "humanoid",
};
act(() => {
result.current.addFromBestiary(entry);
});
act(() => {
result.current.addFromBestiary(entry);
});
const names = result.current.encounter.combatants.map((c) => c.name);
expect(names).toContain("Goblin 1");
expect(names).toContain("Goblin 2");
});
it("addFromPlayerCharacter adds combatant with HP, AC, color, icon", () => {
const { result } = renderHook(() => useEncounter());
const pc: PlayerCharacter = {
id: playerCharacterId("pc-1"),
name: "Aria",
ac: 16,
maxHp: 30,
color: "blue",
icon: "sword",
};
act(() => result.current.addFromPlayerCharacter(pc));
const combatant = result.current.encounter.combatants[0];
expect(combatant.name).toBe("Aria");
expect(combatant.maxHp).toBe(30);
expect(combatant.currentHp).toBe(30);
expect(combatant.ac).toBe(16);
expect(combatant.color).toBe("blue");
expect(combatant.icon).toBe("sword");
expect(combatant.playerCharacterId).toBe(playerCharacterId("pc-1"));
});
});

View File

@@ -0,0 +1,100 @@
// @vitest-environment jsdom
import { playerCharacterId } from "@initiative/domain";
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { usePlayerCharacters } from "../use-player-characters.js";
vi.mock("../../persistence/player-character-storage.js", () => ({
loadPlayerCharacters: vi.fn().mockReturnValue([]),
savePlayerCharacters: vi.fn(),
}));
const { loadPlayerCharacters: mockLoad, savePlayerCharacters: mockSave } =
await vi.importMock<
typeof import("../../persistence/player-character-storage.js")
>("../../persistence/player-character-storage.js");
describe("usePlayerCharacters", () => {
beforeEach(() => {
vi.clearAllMocks();
mockLoad.mockReturnValue([]);
});
it("initializes with characters from persistence", () => {
const stored = [
{
id: playerCharacterId("pc-1"),
name: "Aria",
ac: 16,
maxHp: 30,
color: undefined,
icon: undefined,
},
];
mockLoad.mockReturnValue(stored);
const { result } = renderHook(() => usePlayerCharacters());
expect(result.current.characters).toEqual(stored);
});
it("createCharacter adds a character and persists", () => {
const { result } = renderHook(() => usePlayerCharacters());
act(() => {
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
});
expect(result.current.characters).toHaveLength(1);
expect(result.current.characters[0].name).toBe("Vex");
expect(result.current.characters[0].ac).toBe(15);
expect(result.current.characters[0].maxHp).toBe(28);
expect(mockSave).toHaveBeenCalled();
});
it("createCharacter returns domain error for empty name", () => {
const { result } = renderHook(() => usePlayerCharacters());
let error: unknown;
act(() => {
error = result.current.createCharacter("", 15, 28, undefined, undefined);
});
expect(error).toMatchObject({ kind: "domain-error" });
expect(result.current.characters).toHaveLength(0);
});
it("editCharacter updates character and persists", () => {
const { result } = renderHook(() => usePlayerCharacters());
act(() => {
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
});
const id = result.current.characters[0].id;
act(() => {
result.current.editCharacter(id, { name: "Vex'ahlia" });
});
expect(result.current.characters[0].name).toBe("Vex'ahlia");
expect(mockSave).toHaveBeenCalled();
});
it("deleteCharacter removes character and persists", () => {
const { result } = renderHook(() => usePlayerCharacters());
act(() => {
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
});
const id = result.current.characters[0].id;
act(() => {
result.current.deleteCharacter(id);
});
expect(result.current.characters).toHaveLength(0);
expect(mockSave).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,159 @@
// @vitest-environment jsdom
import { creatureId } from "@initiative/domain";
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useSidePanelState } from "../use-side-panel-state.js";
function mockMatchMedia(matches: boolean) {
const listeners: Array<(e: MediaQueryListEvent) => void> = [];
const mql = {
matches,
addEventListener: vi.fn(
(_event: string, handler: (e: MediaQueryListEvent) => void) => {
listeners.push(handler);
},
),
removeEventListener: vi.fn(),
};
globalThis.matchMedia = vi.fn().mockReturnValue(mql) as typeof matchMedia;
return { mql, listeners };
}
const CREATURE_A = creatureId("creature-a");
describe("useSidePanelState", () => {
it("starts with closed panel, no selection, not collapsed", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
expect(result.current.panelView).toEqual({ mode: "closed" });
expect(result.current.selectedCreatureId).toBeNull();
expect(result.current.isRightPanelCollapsed).toBe(false);
expect(result.current.bulkImportMode).toBe(false);
expect(result.current.sourceManagerMode).toBe(false);
expect(result.current.pinnedCreatureId).toBeNull();
});
it("showCreature sets creature mode and selectedCreatureId", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.showCreature(CREATURE_A));
expect(result.current.panelView).toEqual({
mode: "creature",
creatureId: CREATURE_A,
});
expect(result.current.selectedCreatureId).toBe(CREATURE_A);
});
it("showBulkImport sets bulk-import mode, selectedCreatureId null", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.showBulkImport());
expect(result.current.panelView).toEqual({ mode: "bulk-import" });
expect(result.current.selectedCreatureId).toBeNull();
expect(result.current.bulkImportMode).toBe(true);
});
it("showSourceManager sets source-manager mode", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.showSourceManager());
expect(result.current.panelView).toEqual({ mode: "source-manager" });
expect(result.current.sourceManagerMode).toBe(true);
});
it("dismissPanel sets mode to closed", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.showCreature(CREATURE_A));
act(() => result.current.dismissPanel());
expect(result.current.panelView).toEqual({ mode: "closed" });
});
it("toggleCollapse flips isRightPanelCollapsed", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
expect(result.current.isRightPanelCollapsed).toBe(false);
act(() => result.current.toggleCollapse());
expect(result.current.isRightPanelCollapsed).toBe(true);
act(() => result.current.toggleCollapse());
expect(result.current.isRightPanelCollapsed).toBe(false);
});
it("showCreature resets collapse state", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.toggleCollapse());
expect(result.current.isRightPanelCollapsed).toBe(true);
act(() => result.current.showCreature(CREATURE_A));
expect(result.current.isRightPanelCollapsed).toBe(false);
});
it("togglePin pins the selected creature", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.showCreature(CREATURE_A));
act(() => result.current.togglePin());
expect(result.current.pinnedCreatureId).toBe(CREATURE_A);
});
it("togglePin unpins when already pinned to same creature", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.showCreature(CREATURE_A));
act(() => result.current.togglePin());
act(() => result.current.togglePin());
expect(result.current.pinnedCreatureId).toBeNull();
});
it("togglePin does nothing when no creature is selected", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.togglePin());
expect(result.current.pinnedCreatureId).toBeNull();
});
it("unpin clears pinned creature", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.showCreature(CREATURE_A));
act(() => result.current.togglePin());
act(() => result.current.unpin());
expect(result.current.pinnedCreatureId).toBeNull();
});
it("isWideDesktop reflects matchMedia result", () => {
mockMatchMedia(true);
const { result } = renderHook(() => useSidePanelState());
expect(result.current.isWideDesktop).toBe(true);
});
it("isWideDesktop is false on narrow viewport", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
expect(result.current.isWideDesktop).toBe(false);
});
});

View File

@@ -0,0 +1,38 @@
import { useLayoutEffect, useRef, useState } from "react";
export function useActionBarAnimation(combatantCount: number) {
const wasEmptyRef = useRef(combatantCount === 0);
const [settling, setSettling] = useState(false);
const [rising, setRising] = useState(false);
const [topBarExiting, setTopBarExiting] = useState(false);
useLayoutEffect(() => {
const nowEmpty = combatantCount === 0;
if (wasEmptyRef.current && !nowEmpty) {
setSettling(true);
} else if (!wasEmptyRef.current && nowEmpty) {
setRising(true);
setTopBarExiting(true);
}
wasEmptyRef.current = nowEmpty;
}, [combatantCount]);
const empty = combatantCount === 0;
const risingClass = rising ? "animate-rise-to-center" : "";
const settlingClass = settling ? "animate-settle-to-bottom" : "";
const exitingClass = topBarExiting
? "absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
: "";
const topBarClass = settling ? "animate-slide-down-in" : exitingClass;
const showTopBar = !empty || topBarExiting;
return {
risingClass,
settlingClass,
topBarClass,
showTopBar,
onSettleEnd: () => setSettling(false),
onRiseEnd: () => setRising(false),
onTopBarExitEnd: () => setTopBarExiting(false),
};
}

View File

@@ -0,0 +1,316 @@
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
import { useCallback, useDeferredValue, useMemo, 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,
} = useEncounterContext();
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
useBestiaryContext();
const { characters: playerCharacters } = usePlayerCharactersContext();
const { showBulkImport, showSourceManager, showCreature, panelView } =
useSidePanelContext();
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) => {
const creatureId = addFromBestiary(result);
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
if (creatureId && panelView.mode === "closed" && isDesktop) {
showCreature(creatureId);
}
},
[addFromBestiary, panelView.mode, showCreature],
);
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 {
const creatureId = addMultipleFromBestiary(queued.result, queued.count);
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
if (creatureId && panelView.mode === "closed" && isDesktop) {
showCreature(creatureId);
}
}
clearInput();
}, [
queued,
handleAddFromBestiary,
addMultipleFromBestiary,
panelView.mode,
showCreature,
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;
}

View File

@@ -0,0 +1,27 @@
import { useEffect, useRef } from "react";
import { useEncounterContext } from "../contexts/encounter-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js";
export function useAutoStatBlock(): void {
const { encounter } = useEncounterContext();
const { panelView, updateCreature } = useSidePanelContext();
const activeCreatureId =
encounter.combatants[encounter.activeIndex]?.creatureId;
const prevActiveIndexRef = useRef(encounter.activeIndex);
useEffect(() => {
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);
}
}, [encounter.activeIndex, activeCreatureId, panelView.mode, updateCreature]);
}

View File

@@ -3,7 +3,7 @@ import type {
Creature,
CreatureId,
} from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import {
normalizeBestiary,
setSourceDisplayNames,
@@ -33,8 +33,9 @@ interface BestiaryHook {
export function useBestiary(): BestiaryHook {
const [isLoaded, setIsLoaded] = useState(false);
const creatureMapRef = useRef<Map<CreatureId, Creature>>(new Map());
const [, setTick] = useState(0);
const [creatureMap, setCreatureMap] = useState(
() => new Map<CreatureId, Creature>(),
);
useEffect(() => {
const index = loadBestiaryIndex();
@@ -43,9 +44,8 @@ export function useBestiary(): BestiaryHook {
setIsLoaded(true);
}
bestiaryCache.loadAllCachedCreatures().then((map) => {
creatureMapRef.current = map;
setTick((t) => t + 1);
void bestiaryCache.loadAllCachedCreatures().then((map) => {
setCreatureMap(map);
});
}, []);
@@ -63,9 +63,12 @@ export function useBestiary(): BestiaryHook {
}));
}, []);
const getCreature = useCallback((id: CreatureId): Creature | undefined => {
return creatureMapRef.current.get(id);
}, []);
const getCreature = useCallback(
(id: CreatureId): Creature | undefined => {
return creatureMap.get(id);
},
[creatureMap],
);
const isSourceCachedFn = useCallback(
(sourceCode: string): Promise<boolean> => {
@@ -86,10 +89,13 @@ export function useBestiary(): BestiaryHook {
const creatures = normalizeBestiary(json);
const displayName = getSourceDisplayName(sourceCode);
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
for (const c of creatures) {
creatureMapRef.current.set(c.id, c);
}
setTick((t) => t + 1);
setCreatureMap((prev) => {
const next = new Map(prev);
for (const c of creatures) {
next.set(c.id, c);
}
return next;
});
},
[],
);
@@ -100,18 +106,20 @@ export function useBestiary(): BestiaryHook {
const creatures = normalizeBestiary(jsonData as any);
const displayName = getSourceDisplayName(sourceCode);
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
for (const c of creatures) {
creatureMapRef.current.set(c.id, c);
}
setTick((t) => t + 1);
setCreatureMap((prev) => {
const next = new Map(prev);
for (const c of creatures) {
next.set(c.id, c);
}
return next;
});
},
[],
);
const refreshCache = useCallback(async (): Promise<void> => {
const map = await bestiaryCache.loadAllCachedCreatures();
creatureMapRef.current = map;
setTick((t) => t + 1);
setCreatureMap(map);
}, []);
return {

View File

@@ -6,7 +6,7 @@ import {
const BATCH_SIZE = 6;
export interface BulkImportState {
interface BulkImportState {
readonly status: "idle" | "loading" | "complete" | "partial-failure";
readonly total: number;
readonly completed: number;
@@ -48,7 +48,7 @@ export function useBulkImport(): BulkImportHook {
countersRef.current = { completed: 0, failed: 0 };
setState({ status: "loading", total, completed: 0, failed: 0 });
(async () => {
void (async () => {
const cacheChecks = await Promise.all(
allCodes.map(async (code) => ({
code,
@@ -73,31 +73,39 @@ export function useBulkImport(): BulkImportHook {
setState((s) => ({ ...s, completed: alreadyCached }));
const batches: { code: string }[][] = [];
for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
const batch = uncached.slice(i, i + BATCH_SIZE);
await Promise.allSettled(
batch.map(async ({ code }) => {
const url = getDefaultFetchUrl(code, baseUrl);
try {
await fetchAndCacheSource(code, url);
countersRef.current.completed++;
} catch (err) {
countersRef.current.failed++;
console.warn(
`[bulk-import] FAILED ${code} (${url}):`,
err instanceof Error ? err.message : err,
);
}
setState({
status: "loading",
total,
completed: countersRef.current.completed,
failed: countersRef.current.failed,
});
}),
);
batches.push(uncached.slice(i, i + BATCH_SIZE));
}
await batches.reduce(
(chain, batch) =>
chain.then(() =>
Promise.allSettled(
batch.map(async ({ code }) => {
const url = getDefaultFetchUrl(code, baseUrl);
try {
await fetchAndCacheSource(code, url);
countersRef.current.completed++;
} catch (err) {
countersRef.current.failed++;
console.warn(
`[bulk-import] FAILED ${code} (${url}):`,
err instanceof Error ? err.message : err,
);
}
setState({
status: "loading",
total,
completed: countersRef.current.completed,
failed: countersRef.current.failed,
});
}),
),
),
Promise.resolve() as Promise<unknown>,
);
await refreshCache();
const { completed, failed } = countersRef.current;

View File

@@ -1,29 +1,38 @@
import type { EncounterStore } from "@initiative/application";
import type { EncounterStore, UndoRedoStore } from "@initiative/application";
import {
addCombatantUseCase,
adjustHpUseCase,
advanceTurnUseCase,
clearEncounterUseCase,
editCombatantUseCase,
redoUseCase,
removeCombatantUseCase,
retreatTurnUseCase,
setAcUseCase,
setHpUseCase,
setInitiativeUseCase,
setTempHpUseCase,
toggleConcentrationUseCase,
toggleConditionUseCase,
undoUseCase,
} from "@initiative/application";
import type {
BestiaryIndexEntry,
CombatantId,
CombatantInit,
ConditionId,
CreatureId,
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";
@@ -31,6 +40,12 @@ import {
loadEncounter,
saveEncounter,
} from "../persistence/encounter-storage.js";
import {
loadUndoRedoStacks,
saveUndoRedoStacks,
} from "../persistence/undo-redo-storage.js";
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
const EMPTY_ENCOUNTER: Encounter = {
combatants: [],
@@ -47,7 +62,7 @@ function initializeEncounter(): Encounter {
function deriveNextId(encounter: Encounter): number {
let max = 0;
for (const c of encounter.combatants) {
const match = /^c-(\d+)$/.exec(c.id);
const match = COMBATANT_ID_REGEX.exec(c.id);
if (match) {
const n = Number.parseInt(match[1], 10);
if (n > max) max = n;
@@ -56,43 +71,24 @@ 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 [undoRedoState, setUndoRedoState] =
useState<UndoRedoState>(loadUndoRedoStacks);
const encounterRef = useRef(encounter);
encounterRef.current = encounter;
const undoRedoRef = useRef(undoRedoState);
undoRedoRef.current = undoRedoState;
useEffect(() => {
saveEncounter(encounter);
}, [encounter]);
useEffect(() => {
saveUndoRedoStacks(undoRedoState);
}, [undoRedoState]);
const makeStore = useCallback((): EncounterStore => {
return {
get: () => encounterRef.current,
@@ -103,52 +99,68 @@ export function useEncounter() {
};
}, []);
const makeUndoRedoStore = useCallback((): UndoRedoStore => {
return {
get: () => undoRedoRef.current,
save: (s) => {
undoRedoRef.current = s;
setUndoRedoState(s);
},
};
}, []);
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;
setUndoRedoState(newState);
}
return result;
}, []);
const advanceTurn = useCallback(() => {
const result = advanceTurnUseCase(makeStore());
const result = withUndo(() => advanceTurnUseCase(makeStore()));
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
}, [makeStore]);
}, [makeStore, withUndo]);
const retreatTurn = useCallback(() => {
const result = retreatTurnUseCase(makeStore());
const result = withUndo(() => retreatTurnUseCase(makeStore()));
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
}, [makeStore]);
}, [makeStore, withUndo]);
const nextId = useRef(deriveNextId(encounter));
const addCombatant = useCallback(
(name: string, opts?: CombatantOpts) => {
(name: string, init?: CombatantInit) => {
const id = combatantId(`c-${++nextId.current}`);
const result = addCombatantUseCase(makeStore(), id, name);
const result = withUndo(() =>
addCombatantUseCase(makeStore(), id, name, init),
);
if (isDomainError(result)) {
return;
}
if (opts) {
const optEvents = applyCombatantOpts(makeStore, id, opts);
if (optEvents.length > 0) {
setEvents((prev) => [...prev, ...optEvents]);
}
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
[makeStore, withUndo],
);
const removeCombatant = useCallback(
(id: CombatantId) => {
const result = removeCombatantUseCase(makeStore(), id);
const result = withUndo(() => removeCombatantUseCase(makeStore(), id));
if (isDomainError(result)) {
return;
@@ -156,12 +168,14 @@ export function useEncounter() {
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
[makeStore, withUndo],
);
const editCombatant = useCallback(
(id: CombatantId, newName: string) => {
const result = editCombatantUseCase(makeStore(), id, newName);
const result = withUndo(() =>
editCombatantUseCase(makeStore(), id, newName),
);
if (isDomainError(result)) {
return;
@@ -169,12 +183,14 @@ export function useEncounter() {
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
[makeStore, withUndo],
);
const setInitiative = useCallback(
(id: CombatantId, value: number | undefined) => {
const result = setInitiativeUseCase(makeStore(), id, value);
const result = withUndo(() =>
setInitiativeUseCase(makeStore(), id, value),
);
if (isDomainError(result)) {
return;
@@ -182,12 +198,12 @@ export function useEncounter() {
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
[makeStore, withUndo],
);
const setHp = useCallback(
(id: CombatantId, maxHp: number | undefined) => {
const result = setHpUseCase(makeStore(), id, maxHp);
const result = withUndo(() => setHpUseCase(makeStore(), id, maxHp));
if (isDomainError(result)) {
return;
@@ -195,12 +211,12 @@ export function useEncounter() {
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
[makeStore, withUndo],
);
const adjustHp = useCallback(
(id: CombatantId, delta: number) => {
const result = adjustHpUseCase(makeStore(), id, delta);
const result = withUndo(() => adjustHpUseCase(makeStore(), id, delta));
if (isDomainError(result)) {
return;
@@ -208,12 +224,25 @@ export function useEncounter() {
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
[makeStore, withUndo],
);
const setTempHp = useCallback(
(id: CombatantId, tempHp: number | undefined) => {
const result = withUndo(() => setTempHpUseCase(makeStore(), id, tempHp));
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore, withUndo],
);
const setAc = useCallback(
(id: CombatantId, value: number | undefined) => {
const result = setAcUseCase(makeStore(), id, value);
const result = withUndo(() => setAcUseCase(makeStore(), id, value));
if (isDomainError(result)) {
return;
@@ -221,12 +250,14 @@ export function useEncounter() {
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
[makeStore, withUndo],
);
const toggleCondition = useCallback(
(id: CombatantId, conditionId: ConditionId) => {
const result = toggleConditionUseCase(makeStore(), id, conditionId);
const result = withUndo(() =>
toggleConditionUseCase(makeStore(), id, conditionId),
);
if (isDomainError(result)) {
return;
@@ -234,12 +265,14 @@ export function useEncounter() {
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
[makeStore, withUndo],
);
const toggleConcentration = useCallback(
(id: CombatantId) => {
const result = toggleConcentrationUseCase(makeStore(), id);
const result = withUndo(() =>
toggleConcentrationUseCase(makeStore(), id),
);
if (isDomainError(result)) {
return;
@@ -247,7 +280,7 @@ export function useEncounter() {
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
[makeStore, withUndo],
);
const clearEncounter = useCallback(() => {
@@ -257,12 +290,18 @@ export function useEncounter() {
return;
}
const cleared = clearHistory();
undoRedoRef.current = cleared;
setUndoRedoState(cleared);
nextId.current = 0;
setEvents((prev) => [...prev, ...result]);
}, [makeStore]);
const addFromBestiary = useCallback(
(entry: BestiaryIndexEntry) => {
const addOneFromBestiary = useCallback(
(
entry: BestiaryIndexEntry,
): { cId: CreatureId; events: DomainEvent[] } | null => {
const store = makeStore();
const existingNames = store.get().combatants.map((c) => c.name);
const { newName, renames } = resolveCreatureName(
@@ -270,7 +309,6 @@ export function useEncounter() {
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) {
@@ -278,49 +316,141 @@ export function useEncounter() {
}
}
// Add combatant with resolved name
const id = combatantId(`c-${++nextId.current}`);
const addResult = addCombatantUseCase(makeStore(), id, newName);
if (isDomainError(addResult)) return;
const slug = entry.name
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/(^-|-$)/g, "");
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
// Set HP
const hpResult = setHpUseCase(makeStore(), id, entry.hp);
if (!isDomainError(hpResult)) {
setEvents((prev) => [...prev, ...hpResult]);
const id = combatantId(`c-${++nextId.current}`);
const result = addCombatantUseCase(makeStore(), id, newName, {
maxHp: entry.hp,
ac: entry.ac > 0 ? entry.ac : undefined,
creatureId: cId,
});
if (isDomainError(result)) return null;
return { cId, events: result };
},
[makeStore],
);
const addFromBestiary = useCallback(
(entry: BestiaryIndexEntry): CreatureId | null => {
const snapshot = encounterRef.current;
const added = addOneFromBestiary(entry);
if (!added) {
makeStore().save(snapshot);
return null;
}
// Set AC
if (entry.ac > 0) {
const acResult = setAcUseCase(makeStore(), id, entry.ac);
if (!isDomainError(acResult)) {
setEvents((prev) => [...prev, ...acResult]);
const newState = pushUndo(undoRedoRef.current, snapshot);
undoRedoRef.current = newState;
setUndoRedoState(newState);
setEvents((prev) => [...prev, ...added.events]);
return added.cId;
},
[makeStore, addOneFromBestiary],
);
const addMultipleFromBestiary = useCallback(
(entry: BestiaryIndexEntry, count: number): CreatureId | null => {
const snapshot = encounterRef.current;
const allEvents: DomainEvent[] = [];
let lastCId: CreatureId | null = null;
for (let i = 0; i < count; i++) {
const added = addOneFromBestiary(entry);
if (!added) {
makeStore().save(snapshot);
return null;
}
allEvents.push(...added.events);
lastCId = added.cId;
}
const newState = pushUndo(undoRedoRef.current, snapshot);
undoRedoRef.current = newState;
setUndoRedoState(newState);
setEvents((prev) => [...prev, ...allEvents]);
return lastCId;
},
[makeStore, addOneFromBestiary],
);
const addFromPlayerCharacter = useCallback(
(pc: PlayerCharacter) => {
const snapshot = encounterRef.current;
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);
}
}
// Derive creatureId from source + name
const slug = entry.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
// Set creatureId on the combatant (use store.save to keep ref in sync for batch calls)
const currentEncounter = store.get();
store.save({
...currentEncounter,
combatants: currentEncounter.combatants.map((c) =>
c.id === id ? { ...c, creatureId: cId } : c,
),
const id = combatantId(`c-${++nextId.current}`);
const result = addCombatantUseCase(makeStore(), id, newName, {
maxHp: pc.maxHp,
ac: pc.ac > 0 ? pc.ac : undefined,
color: pc.color,
icon: pc.icon,
playerCharacterId: pc.id,
});
setEvents((prev) => [...prev, ...addResult]);
if (isDomainError(result)) {
store.save(snapshot);
return;
}
const newState = pushUndo(undoRedoRef.current, snapshot);
undoRedoRef.current = newState;
setUndoRedoState(newState);
setEvents((prev) => [...prev, ...result]);
},
[makeStore, editCombatant],
[makeStore],
);
const undoAction = useCallback(() => {
undoUseCase(makeStore(), makeUndoRedoStore());
}, [makeStore, makeUndoRedoStore]);
const redoAction = useCallback(() => {
redoUseCase(makeStore(), makeUndoRedoStore());
}, [makeStore, makeUndoRedoStore]);
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,
);
const canRollAllInitiative = encounter.combatants.some(
(c) => c.creatureId != null && c.initiative == null,
);
return {
encounter,
events,
isEmpty,
hasTempHp,
hasCreatureCombatants,
canRollAllInitiative,
canUndo,
canRedo,
advanceTurn,
retreatTurn,
addCombatant,
@@ -330,10 +460,16 @@ export function useEncounter() {
setInitiative,
setHp,
adjustHp,
setTempHp,
setAc,
toggleCondition,
toggleConcentration,
addFromBestiary,
addMultipleFromBestiary,
addFromPlayerCharacter,
undo: undoAction,
redo: redoAction,
makeStore,
withUndo,
} as const;
}

View File

@@ -0,0 +1,68 @@
import {
rollAllInitiativeUseCase,
rollInitiativeUseCase,
} from "@initiative/application";
import {
type CombatantId,
isDomainError,
type RollMode,
} from "@initiative/domain";
import { useCallback, useState } from "react";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useEncounterContext } from "../contexts/encounter-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js";
function rollDice(): number {
return Math.floor(Math.random() * 20) + 1;
}
export function useInitiativeRolls() {
const { encounter, makeStore, withUndo } = useEncounterContext();
const { getCreature } = useBestiaryContext();
const { showCreature } = useSidePanelContext();
const [rollSkippedCount, setRollSkippedCount] = useState(0);
const [rollSingleSkipped, setRollSingleSkipped] = useState(false);
const handleRollInitiative = useCallback(
(id: CombatantId, mode: RollMode = "normal") => {
const diceRolls: [number, ...number[]] =
mode === "normal" ? [rollDice()] : [rollDice(), rollDice()];
const result = withUndo(() =>
rollInitiativeUseCase(makeStore(), id, diceRolls, getCreature, mode),
);
if (isDomainError(result)) {
setRollSingleSkipped(true);
const combatant = encounter.combatants.find((c) => c.id === id);
if (combatant?.creatureId) {
showCreature(combatant.creatureId);
}
}
},
[makeStore, getCreature, withUndo, encounter.combatants, showCreature],
);
const handleRollAllInitiative = useCallback(
(mode: RollMode = "normal") => {
const result = withUndo(() =>
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature, mode),
);
if (!isDomainError(result) && result.skippedNoSource > 0) {
setRollSkippedCount(result.skippedNoSource);
}
},
[makeStore, getCreature, withUndo],
);
return {
rollSkippedCount,
rollSingleSkipped,
dismissRollSkipped: useCallback(() => setRollSkippedCount(0), []),
dismissRollSingleSkipped: useCallback(
() => setRollSingleSkipped(false),
[],
),
handleRollInitiative,
handleRollAllInitiative,
} as const;
}

View File

@@ -0,0 +1,32 @@
import { useCallback, useRef } from "react";
const LONG_PRESS_MS = 500;
export function useLongPress(onLongPress: (e: React.TouchEvent) => void) {
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const firedRef = useRef(false);
const onTouchStart = useCallback(
(e: React.TouchEvent) => {
firedRef.current = false;
timerRef.current = setTimeout(() => {
firedRef.current = true;
onLongPress(e);
}, LONG_PRESS_MS);
},
[onLongPress],
);
const onTouchEnd = useCallback((e: React.TouchEvent) => {
clearTimeout(timerRef.current);
if (firedRef.current) {
e.preventDefault();
}
}, []);
const onTouchMove = useCallback(() => {
clearTimeout(timerRef.current);
}, []);
return { onTouchStart, onTouchEnd, onTouchMove };
}

View File

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

View File

@@ -0,0 +1,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;
}

View File

@@ -0,0 +1,107 @@
import type { CreatureId } from "@initiative/domain";
import { useCallback, useEffect, useState } from "react";
type PanelView =
| { mode: "closed" }
| { mode: "creature"; creatureId: CreatureId }
| { mode: "bulk-import" }
| { mode: "source-manager" };
interface SidePanelState {
panelView: PanelView;
selectedCreatureId: CreatureId | null;
bulkImportMode: boolean;
sourceManagerMode: boolean;
isRightPanelCollapsed: boolean;
pinnedCreatureId: CreatureId | null;
isWideDesktop: boolean;
}
interface SidePanelActions {
showCreature: (creatureId: CreatureId) => void;
updateCreature: (creatureId: CreatureId) => void;
showBulkImport: () => void;
showSourceManager: () => void;
dismissPanel: () => void;
toggleCollapse: () => void;
togglePin: () => void;
unpin: () => void;
}
export function useSidePanelState(): SidePanelState & SidePanelActions {
const [panelView, setPanelView] = useState<PanelView>({ mode: "closed" });
const [isRightPanelCollapsed, setIsRightPanelCollapsed] = useState(false);
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
null,
);
const [isWideDesktop, setIsWideDesktop] = useState(
() => globalThis.matchMedia("(min-width: 1280px)").matches,
);
useEffect(() => {
const mq = globalThis.matchMedia("(min-width: 1280px)");
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const selectedCreatureId =
panelView.mode === "creature" ? panelView.creatureId : null;
const showCreature = useCallback((creatureId: CreatureId) => {
setPanelView({ mode: "creature", creatureId });
setIsRightPanelCollapsed(false);
}, []);
const updateCreature = useCallback((creatureId: CreatureId) => {
setPanelView({ mode: "creature", creatureId });
}, []);
const showBulkImport = useCallback(() => {
setPanelView({ mode: "bulk-import" });
setIsRightPanelCollapsed(false);
}, []);
const showSourceManager = useCallback(() => {
setPanelView({ mode: "source-manager" });
setIsRightPanelCollapsed(false);
}, []);
const dismissPanel = useCallback(() => {
setPanelView({ mode: "closed" });
}, []);
const toggleCollapse = useCallback(() => {
setIsRightPanelCollapsed((f) => !f);
}, []);
const togglePin = useCallback(() => {
if (selectedCreatureId) {
setPinnedCreatureId((prev) =>
prev === selectedCreatureId ? null : selectedCreatureId,
);
}
}, [selectedCreatureId]);
const unpin = useCallback(() => {
setPinnedCreatureId(null);
}, []);
return {
panelView,
selectedCreatureId,
bulkImportMode: panelView.mode === "bulk-import",
sourceManagerMode: panelView.mode === "source-manager",
isRightPanelCollapsed,
pinnedCreatureId,
isWideDesktop,
showCreature,
updateCreature,
showBulkImport,
showSourceManager,
dismissPanel,
toggleCollapse,
togglePin,
unpin,
};
}

View File

@@ -0,0 +1,93 @@
import { useCallback, useEffect, useSyncExternalStore } from "react";
type ThemePreference = "system" | "light" | "dark";
type ResolvedTheme = "light" | "dark";
const STORAGE_KEY = "initiative:theme";
const listeners = new Set<() => void>();
let currentPreference: ThemePreference = loadPreference();
function loadPreference(): ThemePreference {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === "light" || raw === "dark" || raw === "system") return raw;
} catch {
// storage unavailable
}
return "system";
}
function savePreference(pref: ThemePreference): void {
try {
localStorage.setItem(STORAGE_KEY, pref);
} catch {
// quota exceeded or storage unavailable
}
}
function getSystemTheme(): ResolvedTheme {
if (typeof globalThis.matchMedia !== "function") return "dark";
return globalThis.matchMedia("(prefers-color-scheme: light)").matches
? "light"
: "dark";
}
function resolve(pref: ThemePreference): ResolvedTheme {
return pref === "system" ? getSystemTheme() : pref;
}
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 {
for (const listener of listeners) {
listener();
}
}
// Apply on load
applyTheme(resolve(currentPreference));
// Listen for OS preference changes
if (typeof globalThis.matchMedia === "function") {
globalThis
.matchMedia("(prefers-color-scheme: light)")
.addEventListener("change", () => {
if (currentPreference === "system") {
applyTheme(resolve("system"));
notifyAll();
}
});
}
function subscribe(callback: () => void): () => void {
listeners.add(callback);
return () => listeners.delete(callback);
}
function getSnapshot(): ThemePreference {
return currentPreference;
}
export function useTheme() {
const preference = useSyncExternalStore(subscribe, getSnapshot);
const resolved = resolve(preference);
useEffect(() => {
applyTheme(resolved);
}, [resolved]);
const setPreference = useCallback((pref: ThemePreference) => {
currentPreference = pref;
savePreference(pref);
applyTheme(resolve(pref));
notifyAll();
}, []);
return { preference, resolved, setPreference } as const;
}

View File

@@ -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]);
}

View File

@@ -1,14 +1,14 @@
@import "tailwindcss";
@theme {
--color-background: #0f172a;
--color-background: #0e1a2e;
--color-foreground: #e2e8f0;
--color-muted: #64748b;
--color-muted: #7a8ba4;
--color-muted-foreground: #94a3b8;
--color-card: #1e293b;
--color-card: #1a2e4a;
--color-card-foreground: #e2e8f0;
--color-border: #334155;
--color-input: #334155;
--color-border: #2a5088;
--color-input: #2a5088;
--color-primary: #3b82f6;
--color-primary-foreground: #ffffff;
--color-accent: #3b82f6;
@@ -16,15 +16,50 @@
--color-hover-neutral: var(--color-primary);
--color-hover-action: var(--color-primary);
--color-hover-destructive: var(--color-destructive);
--color-hover-neutral-bg: var(--color-card);
--color-hover-neutral-bg: oklch(0.623 0.214 259 / 0.15);
--color-hover-action-bg: var(--color-muted);
--color-hover-destructive-bg: transparent;
--color-stat-heading: #fbbf24;
--color-stat-divider-from: oklch(0.5 0.1 65 / 0.6);
--color-stat-divider-via: oklch(0.5 0.1 65 / 0.4);
--color-hp-damage-hover-bg: oklch(0.25 0.05 25);
--color-hp-heal-hover-bg: oklch(0.25 0.05 155);
--color-active-row-bg: oklch(0.623 0.214 259 / 0.1);
--color-active-row-border: oklch(0.623 0.214 259 / 0.4);
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
}
[data-theme="light"] {
--color-background: #eeecea;
--color-foreground: #374151;
--color-muted: #e0ddd9;
--color-muted-foreground: #6b7280;
--color-card: #f7f6f4;
--color-card-foreground: #374151;
--color-border: #ddd9d5;
--color-input: #cdc8c3;
--color-primary: #2563eb;
--color-primary-foreground: #ffffff;
--color-accent: #2563eb;
--color-destructive: #dc2626;
--color-hover-neutral: var(--color-primary);
--color-hover-action: var(--color-primary);
--color-hover-destructive: var(--color-destructive);
--color-hover-neutral-bg: oklch(0.623 0.214 259 / 0.08);
--color-hover-action-bg: var(--color-muted);
--color-hover-destructive-bg: transparent;
--color-stat-heading: #92400e;
--color-stat-divider-from: oklch(0.55 0.1 65 / 0.5);
--color-stat-divider-via: oklch(0.55 0.1 65 / 0.25);
--color-hp-damage-hover-bg: #fef2f2;
--color-hp-heal-hover-bg: #ecfdf5;
--color-active-row-bg: oklch(0.623 0.214 259 / 0.08);
--color-active-row-border: oklch(0.623 0.214 259 / 0.25);
}
@keyframes concentration-shake {
0% {
translate: 0;
@@ -80,6 +115,75 @@
}
}
@keyframes settle-to-bottom {
from {
transform: translateY(-40vh);
opacity: 0;
}
40% {
opacity: 1;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@utility animate-settle-to-bottom {
animation: settle-to-bottom 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
@keyframes rise-to-center {
from {
transform: translateY(40vh);
opacity: 0;
}
40% {
opacity: 1;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@utility animate-rise-to-center {
animation: rise-to-center 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
@keyframes slide-down-in {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@utility animate-slide-down-in {
animation: slide-down-in 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
@keyframes slide-up-out {
from {
transform: translateY(0);
opacity: 1;
}
60% {
opacity: 0;
}
to {
transform: translateY(-100%);
opacity: 0;
}
}
@utility animate-slide-up-out {
animation: slide-up-out 700ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
@custom-variant pointer-coarse (@media (pointer: coarse));
@utility animate-confirm-pulse {
@@ -100,6 +204,38 @@
concentration-glow 1200ms ease-out;
}
@utility card-glow {
background-image: radial-gradient(
ellipse at 50% 50%,
oklch(0.35 0.05 250 / 0.5) 0%,
transparent 70%
);
box-shadow:
0 0 15px -2px oklch(0.623 0.214 259 / 0.2),
inset 0 1px 0 0 oklch(0.7 0.15 259 / 0.1);
[data-theme="light"] & {
background-image: none;
box-shadow: 0 1px 3px 0 oklch(0 0 0 / 0.08);
}
}
@utility panel-glow {
background-image: linear-gradient(
to bottom,
oklch(0.35 0.05 250 / 0.4) 0%,
transparent 40%
);
box-shadow:
0 0 20px -2px oklch(0.623 0.214 259 / 0.15),
inset 0 1px 0 0 oklch(0.7 0.15 259 / 0.1);
[data-theme="light"] & {
background-image: none;
box-shadow: -1px 0 6px 0 oklch(0 0 0 / 0.1);
}
}
* {
scrollbar-color: var(--color-border) transparent;
scrollbar-width: thin;
@@ -107,6 +243,16 @@
body {
background-color: var(--color-background);
background-image: radial-gradient(
ellipse at 50% 40%,
oklch(0.26 0.055 250) 0%,
var(--color-background) 70%
);
background-attachment: fixed;
color: var(--color-foreground);
font-family: var(--font-sans);
}
[data-theme="light"] body {
background-image: none;
}

View File

@@ -1,13 +1,39 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { App } from "./App.js";
import {
BestiaryProvider,
BulkImportProvider,
EncounterProvider,
InitiativeRollsProvider,
PlayerCharactersProvider,
RulesEditionProvider,
SidePanelProvider,
ThemeProvider,
} from "./contexts/index.js";
import "./index.css";
const root = document.getElementById("root");
if (root) {
createRoot(root).render(
<StrictMode>
<App />
<ThemeProvider>
<RulesEditionProvider>
<EncounterProvider>
<BestiaryProvider>
<PlayerCharactersProvider>
<BulkImportProvider>
<SidePanelProvider>
<InitiativeRollsProvider>
<App />
</InitiativeRollsProvider>
</SidePanelProvider>
</BulkImportProvider>
</PlayerCharactersProvider>
</BestiaryProvider>
</EncounterProvider>
</RulesEditionProvider>
</ThemeProvider>
</StrictMode>,
);
}

View File

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

View File

@@ -5,7 +5,10 @@ import {
creatureId,
type Encounter,
isDomainError,
playerCharacterId,
VALID_CONDITION_IDS,
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
} from "@initiative/domain";
const STORAGE_KEY = "initiative:encounter";
@@ -70,12 +73,29 @@ function rehydrateCombatant(c: unknown) {
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);
@@ -88,45 +108,44 @@ function isValidCombatantEntry(c: unknown): boolean {
return typeof entry.id === "string" && typeof entry.name === "string";
}
export function rehydrateEncounter(parsed: unknown): Encounter | null {
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
return null;
const obj = parsed as Record<string, unknown>;
if (!Array.isArray(obj.combatants)) return null;
if (typeof obj.activeIndex !== "number") return null;
if (typeof obj.roundNumber !== "number") return null;
const combatants = obj.combatants as unknown[];
// Handle empty encounter (cleared state) directly — createEncounter rejects empty arrays
if (combatants.length === 0) {
return {
combatants: [],
activeIndex: 0,
roundNumber: 1,
};
}
if (!combatants.every(isValidCombatantEntry)) return null;
const rehydrated = combatants.map(rehydrateCombatant);
const result = createEncounter(rehydrated, obj.activeIndex, obj.roundNumber);
if (isDomainError(result)) return null;
return result;
}
export function loadEncounter(): Encounter | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) return null;
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
return null;
const obj = parsed as Record<string, unknown>;
if (!Array.isArray(obj.combatants)) return null;
if (typeof obj.activeIndex !== "number") return null;
if (typeof obj.roundNumber !== "number") return null;
const combatants = obj.combatants as unknown[];
// Handle empty encounter (cleared state) directly — createEncounter rejects empty arrays
if (combatants.length === 0) {
return {
combatants: [],
activeIndex: 0,
roundNumber: 1,
};
}
if (!combatants.every(isValidCombatantEntry)) return null;
const rehydrated = combatants.map(rehydrateCombatant);
const result = createEncounter(
rehydrated,
obj.activeIndex,
obj.roundNumber,
);
if (isDomainError(result)) return null;
return result;
return rehydrateEncounter(parsed);
} catch {
return null;
}

View File

@@ -0,0 +1,77 @@
import type { PlayerCharacter } from "@initiative/domain";
import {
playerCharacterId,
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
} from "@initiative/domain";
const STORAGE_KEY = "initiative:player-characters";
export function savePlayerCharacters(characters: PlayerCharacter[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(characters));
} catch {
// Silently swallow errors (quota exceeded, storage unavailable)
}
}
function 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);
if (raw === null) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
const characters: PlayerCharacter[] = [];
for (const item of parsed) {
const pc = rehydrateCharacter(item);
if (pc !== null) {
characters.push(pc);
}
}
return characters;
} catch {
return [];
}
}

View File

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

View File

@@ -1,14 +1,16 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
"files": {
"includes": [
"**",
"!**/dist/**",
"!.claude/**",
"!.specify/**",
"!specs/**",
"!coverage/**",
"!.pnpm-store/**"
"!**/dist",
"!.claude",
"!.specify",
"!specs",
"!coverage",
"!.pnpm-store",
"!.rodney",
"!.agent-tests"
]
},
"assist": {
@@ -21,6 +23,12 @@
}
}
},
"css": {
"parser": {
"cssModules": false,
"tailwindDirectives": true
}
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
@@ -30,13 +38,93 @@
"enabled": true,
"rules": {
"recommended": true,
"a11y": {
"noNoninteractiveElementInteractions": "error"
},
"complexity": {
"noExcessiveCognitiveComplexity": {
"level": "error",
"options": {
"maxAllowedComplexity": 15
}
}
},
"noUselessStringConcat": "error"
},
"correctness": {
"noNestedComponentDefinitions": "error",
"noReactPropAssignments": "error"
},
"nursery": {
"noConditionalExpect": "error",
"noDuplicatedSpreadProps": "error",
"noFloatingPromises": "error",
"noLeakedRender": "error",
"noMisusedPromises": "error",
"noNestedPromises": "error",
"noReturnAssign": "error",
"noScriptUrl": "error",
"noShadow": "error",
"noUnnecessaryConditions": "error",
"noUselessReturn": "error",
"useArraySome": "error",
"useArraySortCompare": "error",
"useAwaitThenable": "error",
"useErrorCause": "error",
"useExhaustiveSwitchCases": "error",
"useFind": "error",
"useGlobalThis": "error",
"useNullishCoalescing": "error",
"useRegexpExec": "error",
"useSortedClasses": "error",
"useSpread": "error"
},
"performance": {
"noAwaitInLoops": "error",
"useTopLevelRegex": "error"
},
"style": {
"noCommonJs": "error",
"noDoneCallback": "error",
"noExportedImports": "error",
"noInferrableTypes": "error",
"noNamespace": "error",
"noNegationElse": "error",
"noNestedTernary": "error",
"noParameterAssign": "error",
"noSubstr": "error",
"noUnusedTemplateLiteral": "error",
"noUselessElse": "error",
"noYodaExpression": "error",
"useAsConstAssertion": "error",
"useAtIndex": "error",
"useCollapsedElseIf": "error",
"useCollapsedIf": "error",
"useConsistentBuiltinInstantiation": "error",
"useDefaultParameterLast": "error",
"useExplicitLengthCheck": "error",
"useForOf": "error",
"useFragmentSyntax": "error",
"useNumberNamespace": "error",
"useSelfClosingElements": "error",
"useShorthandAssign": "error",
"useThrowNewError": "error",
"useThrowOnlyError": "error",
"useTrimStartEnd": "error"
},
"suspicious": {
"noAlert": "error",
"noConstantBinaryExpressions": "error",
"noDeprecatedImports": "error",
"noEvolvingTypes": "error",
"noImportCycles": "error",
"noReactForwardRef": "error",
"noSkippedTests": "error",
"noTemplateCurlyInString": "error",
"noTsIgnore": "error",
"noUnusedExpressions": "error",
"noVar": "error",
"useAwait": "error",
"useErrorMessage": "error"
}
}
}

20
docs/adr/000-template.md Normal file
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?

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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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