63 Commits

Author SHA1 Message Date
Lukas
6584d8d064 Add advantage/disadvantage rolling for initiative
All checks were successful
CI / check (push) Successful in 1m23s
CI / build-image (push) Has been skipped
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
116 changed files with 8219 additions and 1214 deletions

1
.gitignore vendored
View File

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

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

@@ -5,7 +5,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Commands ## Commands
```bash ```bash
pnpm check # Merge gate — must pass before every commit (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 knip # Unused code detection (Knip)
pnpm test # Run all tests (Vitest) pnpm test # Run all tests (Vitest)
pnpm test:watch # Tests in watch mode pnpm test:watch # Tests in watch mode
@@ -58,12 +59,13 @@ docs/agents/ RPI skill artifacts (research reports, plans)
- React 19, Vite 6, Tailwind CSS v4 - React 19, Vite 6, Tailwind CSS v4
- Lucide React (icons) - Lucide React (icons)
- `idb` (IndexedDB wrapper for bestiary cache) - `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) - Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
## Conventions ## Conventions
- **Biome 2.0** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically. - **Biome 2.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`). - **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. - **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical.
- **Domain events** are plain data objects with a `type` discriminant — no classes. - **Domain events** are plain data objects with a `type` discriminant — no classes.
@@ -71,6 +73,14 @@ docs/agents/ RPI skill artifacts (research reports, plans)
- **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`. - **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`.
- **Quality gates** are enforced at pre-commit via Lefthook's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process. - **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 Workflow
Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Specs are **living documents** that describe features, not individual changes. Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Specs are **living documents** that describe features, not individual changes.
@@ -101,6 +111,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/002-turn-tracking/` — rounds, turn order, advance/retreat, top bar
- `specs/003-combatant-state/` — HP, AC, conditions, concentration, initiative - `specs/003-combatant-state/` — HP, AC, conditions, concentration, initiative
- `specs/004-bestiary/` — search index, stat blocks, source management, panel UX - `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) ## Constitution (key principles)
@@ -111,3 +122,10 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
3. **Clarification-First** — Ask before making non-trivial assumptions. 3. **Clarification-First** — Ask before making non-trivial assumptions.
4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans. 4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans.
5. **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. 5. **Spec-driven features** — Features are described in living specs; evolve existing specs via `/integrate-issue`, create new ones via `/speckit.specify`. Bug fixes and tooling changes do not require specs.
## Active Technologies
- TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Vite 6, Tailwind CSS v4, Lucide Reac (005-player-characters)
- localStorage (new key `"initiative:player-characters"`) (005-player-characters)
## Recent Changes
- 005-player-characters: Added TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Vite 6, Tailwind CSS v4, Lucide Reac

View File

@@ -7,7 +7,8 @@ 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 - **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 - **Encounter state** — HP, AC, conditions, concentration tracking with visual status indicators
- **Bestiary integration** — import bestiary JSON sources, search creatures, and view full stat blocks - **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
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB; player characters stored independently
## Prerequisites ## Prerequisites

View File

@@ -23,6 +23,7 @@
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",

View File

@@ -2,25 +2,85 @@ import {
rollAllInitiativeUseCase, rollAllInitiativeUseCase,
rollInitiativeUseCase, rollInitiativeUseCase,
} from "@initiative/application"; } from "@initiative/application";
import type { CombatantId, Creature, CreatureId } from "@initiative/domain"; import {
import { useCallback, useEffect, useRef, useState } from "react"; type CombatantId,
type Creature,
type CreatureId,
isDomainError,
type RollMode,
} from "@initiative/domain";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { ActionBar } from "./components/action-bar"; import { ActionBar } from "./components/action-bar";
import { BulkImportToasts } from "./components/bulk-import-toasts";
import { CombatantRow } from "./components/combatant-row"; import { CombatantRow } from "./components/combatant-row";
import { SourceManager } from "./components/source-manager"; import {
PlayerCharacterSection,
type PlayerCharacterSectionHandle,
} from "./components/player-character-section";
import { StatBlockPanel } from "./components/stat-block-panel"; import { StatBlockPanel } from "./components/stat-block-panel";
import { Toast } from "./components/toast"; import { Toast } from "./components/toast";
import { TurnNavigation } from "./components/turn-navigation"; import { TurnNavigation } from "./components/turn-navigation";
import { type SearchResult, useBestiary } from "./hooks/use-bestiary"; import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
import { useBulkImport } from "./hooks/use-bulk-import"; import { useBulkImport } from "./hooks/use-bulk-import";
import { useEncounter } from "./hooks/use-encounter"; import { useEncounter } from "./hooks/use-encounter";
import { usePlayerCharacters } from "./hooks/use-player-characters";
import { useSidePanelState } from "./hooks/use-side-panel-state";
import { useTheme } from "./hooks/use-theme";
import { cn } from "./lib/utils";
function rollDice(): number { function rollDice(): number {
return Math.floor(Math.random() * 20) + 1; return Math.floor(Math.random() * 20) + 1;
} }
function useActionBarAnimation(combatantCount: number) {
const wasEmptyRef = useRef(combatantCount === 0);
const [settling, setSettling] = useState(false);
const [rising, setRising] = useState(false);
const [topBarExiting, setTopBarExiting] = useState(false);
useLayoutEffect(() => {
const nowEmpty = combatantCount === 0;
if (wasEmptyRef.current && !nowEmpty) {
setSettling(true);
} else if (!wasEmptyRef.current && nowEmpty) {
setRising(true);
setTopBarExiting(true);
}
wasEmptyRef.current = nowEmpty;
}, [combatantCount]);
const empty = combatantCount === 0;
const risingClass = rising ? "animate-rise-to-center" : "";
const settlingClass = settling ? "animate-settle-to-bottom" : "";
const 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),
};
}
export function App() { export function App() {
const { const {
encounter, encounter,
isEmpty,
hasCreatureCombatants,
canRollAllInitiative,
advanceTurn, advanceTurn,
retreatTurn, retreatTurn,
addCombatant, addCombatant,
@@ -34,9 +94,17 @@ export function App() {
toggleCondition, toggleCondition,
toggleConcentration, toggleConcentration,
addFromBestiary, addFromBestiary,
addFromPlayerCharacter,
makeStore, makeStore,
} = useEncounter(); } = useEncounter();
const {
characters: playerCharacters,
createCharacter: createPlayerCharacter,
editCharacter: editPlayerCharacter,
deleteCharacter: deletePlayerCharacter,
} = usePlayerCharacters();
const { const {
search, search,
getCreature, getCreature,
@@ -48,79 +116,85 @@ export function App() {
} = useBestiary(); } = useBestiary();
const bulkImport = useBulkImport(); const bulkImport = useBulkImport();
const sidePanel = useSidePanelState();
const { preference: themePreference, cycleTheme } = useTheme();
const [selectedCreatureId, setSelectedCreatureId] = const [rollSkippedCount, setRollSkippedCount] = useState(0);
useState<CreatureId | null>(null); const [rollSingleSkipped, setRollSingleSkipped] = useState(false);
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 selectedCreature: Creature | null = sidePanel.selectedCreatureId
const mq = window.matchMedia("(min-width: 1280px)"); ? (getCreature(sidePanel.selectedCreatureId) ?? null)
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; : null;
const pinnedCreature: Creature | null = pinnedCreatureId const pinnedCreature: Creature | null = sidePanel.pinnedCreatureId
? (getCreature(pinnedCreatureId) ?? null) ? (getCreature(sidePanel.pinnedCreatureId) ?? null)
: null; : null;
const handleAddFromBestiary = useCallback( const handleAddFromBestiary = useCallback(
(result: SearchResult) => { (result: SearchResult) => {
addFromBestiary(result); const creatureId = addFromBestiary(result);
// Derive the creature ID so stat block panel can try to show it if (creatureId && sidePanel.panelView.mode === "closed") {
const slug = result.name sidePanel.showCreature(creatureId);
.toLowerCase() }
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
setSelectedCreatureId(
`${result.source.toLowerCase()}:${slug}` as CreatureId,
);
}, },
[addFromBestiary], [addFromBestiary, sidePanel.panelView.mode, sidePanel.showCreature],
); );
const handleCombatantStatBlock = useCallback((creatureId: string) => { const handleCombatantStatBlock = useCallback(
setSelectedCreatureId(creatureId as CreatureId); (creatureId: string) => {
setIsRightPanelFolded(false); sidePanel.showCreature(creatureId as CreatureId);
}, []); },
[sidePanel.showCreature],
);
const handleRollInitiative = useCallback( const handleRollInitiative = useCallback(
(id: CombatantId) => { (id: CombatantId, mode: RollMode = "normal") => {
rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature); const diceRolls: [number, ...number[]] =
mode === "normal" ? [rollDice()] : [rollDice(), rollDice()];
const result = rollInitiativeUseCase(
makeStore(),
id,
diceRolls,
getCreature,
mode,
);
if (isDomainError(result)) {
setRollSingleSkipped(true);
const combatant = encounter.combatants.find((c) => c.id === id);
if (combatant?.creatureId) {
sidePanel.showCreature(combatant.creatureId);
}
}
},
[makeStore, getCreature, encounter.combatants, sidePanel.showCreature],
);
const handleRollAllInitiative = useCallback(
(mode: RollMode = "normal") => {
const result = rollAllInitiativeUseCase(
makeStore(),
rollDice,
getCreature,
mode,
);
if (!isDomainError(result) && result.skippedNoSource > 0) {
setRollSkippedCount(result.skippedNoSource);
}
}, },
[makeStore, getCreature], [makeStore, getCreature],
); );
const handleRollAllInitiative = useCallback(() => { const handleViewStatBlock = useCallback(
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature); (result: SearchResult) => {
}, [makeStore, getCreature]);
const handleViewStatBlock = useCallback((result: SearchResult) => {
const slug = result.name const slug = result.name
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9]+/g, "-") .replaceAll(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, ""); .replaceAll(/(^-|-$)/g, "");
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId; const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
setSelectedCreatureId(cId); sidePanel.showCreature(cId);
setIsRightPanelFolded(false); },
}, []); [sidePanel.showCreature],
);
const handleBulkImport = useCallback(() => {
setBulkImportMode(true);
setSelectedCreatureId(null);
}, []);
const handleStartBulkImport = useCallback( const handleStartBulkImport = useCallback(
(baseUrl: string) => { (baseUrl: string) => {
@@ -135,30 +209,22 @@ export function App() {
); );
const handleBulkImportDone = useCallback(() => { const handleBulkImportDone = useCallback(() => {
setBulkImportMode(false); sidePanel.dismissPanel();
bulkImport.reset(); bulkImport.reset();
}, [bulkImport.reset]); }, [sidePanel.dismissPanel, bulkImport.reset]);
const handleDismissBrowsePanel = useCallback(() => { const actionBarInputRef = useRef<HTMLInputElement>(null);
setSelectedCreatureId(null); const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
setBulkImportMode(false); const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
}, []);
const handleToggleFold = useCallback(() => { // Auto-update stat block panel when the active combatant changes
setIsRightPanelFolded((f) => !f); const activeCreatureId =
}, []); encounter.combatants[encounter.activeIndex]?.creatureId;
useEffect(() => {
const handlePin = useCallback(() => { if (activeCreatureId && sidePanel.panelView.mode === "creature") {
if (selectedCreatureId) { sidePanel.updateCreature(activeCreatureId);
setPinnedCreatureId((prev) =>
prev === selectedCreatureId ? null : selectedCreatureId,
);
} }
}, [selectedCreatureId]); }, [activeCreatureId, sidePanel.panelView.mode, sidePanel.updateCreature]);
const handleUnpin = useCallback(() => {
setPinnedCreatureId(null);
}, []);
// Auto-scroll to the active combatant when the turn changes // Auto-scroll to the active combatant when the turn changes
const activeRowRef = useRef<HTMLDivElement>(null); const activeRowRef = useRef<HTMLDivElement>(null);
@@ -167,51 +233,62 @@ export function App() {
block: "nearest", block: "nearest",
behavior: "smooth", behavior: "smooth",
}); });
}, [encounter.activeIndex]); }, []);
// 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);
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]);
return ( return (
<div className="flex h-screen flex-col"> <div className="flex h-dvh flex-col">
<div className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0"> <div className="relative mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-3 px-4">
{/* Turn Navigation — fixed at top */} {!!actionBarAnim.showTopBar && (
<div className="shrink-0 pt-8"> <div
className={cn("shrink-0 pt-8", actionBarAnim.topBarClass)}
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
>
<TurnNavigation <TurnNavigation
encounter={encounter} encounter={encounter}
onAdvanceTurn={advanceTurn} onAdvanceTurn={advanceTurn}
onRetreatTurn={retreatTurn} onRetreatTurn={retreatTurn}
onClearEncounter={clearEncounter} onClearEncounter={clearEncounter}
onRollAllInitiative={handleRollAllInitiative}
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
/> />
</div> </div>
{sourceManagerOpen && (
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
<SourceManager onCacheCleared={refreshCache} />
</div>
)} )}
{/* Scrollable area — combatant list */} {isEmpty ? (
<div className="flex-1 overflow-y-auto min-h-0"> /* Empty state — ActionBar centered */
<div className="flex flex-col px-2 py-2"> <div className="flex min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]">
{encounter.combatants.length === 0 ? ( <div
<p className="py-12 text-center text-sm text-muted-foreground"> className={cn("w-full", actionBarAnim.risingClass)}
No combatants yet add one to get started onAnimationEnd={actionBarAnim.onRiseEnd}
</p> >
<ActionBar
onAddCombatant={addCombatant}
onAddFromBestiary={handleAddFromBestiary}
bestiarySearch={search}
bestiaryLoaded={isLoaded}
onViewStatBlock={handleViewStatBlock}
onBulkImport={sidePanel.showBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"}
inputRef={actionBarInputRef}
playerCharacters={playerCharacters}
onAddFromPlayerCharacter={addFromPlayerCharacter}
onManagePlayers={() =>
playerCharacterRef.current?.openManagement()
}
onRollAllInitiative={handleRollAllInitiative}
showRollAllInitiative={hasCreatureCombatants}
rollAllInitiativeDisabled={!canRollAllInitiative}
onOpenSourceManager={sidePanel.showSourceManager}
themePreference={themePreference}
onCycleTheme={cycleTheme}
autoFocus
/>
</div>
</div>
) : ( ) : (
encounter.combatants.map((c, i) => ( <>
{/* Scrollable area — combatant list */}
<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 <CombatantRow
key={c.id} key={c.id}
ref={i === encounter.activeIndex ? activeRowRef : null} ref={i === encounter.activeIndex ? activeRowRef : null}
@@ -230,43 +307,62 @@ export function App() {
? () => handleCombatantStatBlock(c.creatureId as string) ? () => handleCombatantStatBlock(c.creatureId as string)
: undefined : undefined
} }
isStatBlockOpen={
c.creatureId === sidePanel.selectedCreatureId
}
onRollInitiative={ onRollInitiative={
c.creatureId ? handleRollInitiative : undefined c.creatureId ? handleRollInitiative : undefined
} }
/> />
)) ))}
)}
</div> </div>
</div> </div>
{/* Action Bar — fixed at bottom */} {/* Action Bar — fixed at bottom */}
<div className="shrink-0 pb-8"> <div
className={cn("shrink-0 pb-8", actionBarAnim.settlingClass)}
onAnimationEnd={actionBarAnim.onSettleEnd}
>
<ActionBar <ActionBar
onAddCombatant={addCombatant} onAddCombatant={addCombatant}
onAddFromBestiary={handleAddFromBestiary} onAddFromBestiary={handleAddFromBestiary}
bestiarySearch={search} bestiarySearch={search}
bestiaryLoaded={isLoaded} bestiaryLoaded={isLoaded}
onViewStatBlock={handleViewStatBlock} onViewStatBlock={handleViewStatBlock}
onBulkImport={handleBulkImport} onBulkImport={sidePanel.showBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"} bulkImportDisabled={bulkImport.state.status === "loading"}
inputRef={actionBarInputRef}
playerCharacters={playerCharacters}
onAddFromPlayerCharacter={addFromPlayerCharacter}
onManagePlayers={() =>
playerCharacterRef.current?.openManagement()
}
onRollAllInitiative={handleRollAllInitiative}
showRollAllInitiative={hasCreatureCombatants}
rollAllInitiativeDisabled={!canRollAllInitiative}
onOpenSourceManager={sidePanel.showSourceManager}
themePreference={themePreference}
onCycleTheme={cycleTheme}
/> />
</div> </div>
</>
)}
</div> </div>
{/* Pinned Stat Block Panel (left) */} {/* Pinned Stat Block Panel (left) */}
{pinnedCreatureId && isWideDesktop && ( {!!sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
<StatBlockPanel <StatBlockPanel
creatureId={pinnedCreatureId} creatureId={sidePanel.pinnedCreatureId}
creature={pinnedCreature} creature={pinnedCreature}
isSourceCached={isSourceCached} isSourceCached={isSourceCached}
fetchAndCacheSource={fetchAndCacheSource} fetchAndCacheSource={fetchAndCacheSource}
uploadAndCacheSource={uploadAndCacheSource} uploadAndCacheSource={uploadAndCacheSource}
refreshCache={refreshCache} refreshCache={refreshCache}
panelRole="pinned" panelRole="pinned"
isFolded={false} isCollapsed={false}
onToggleFold={() => {}} onToggleCollapse={() => {}}
onPin={() => {}} onPin={() => {}}
onUnpin={handleUnpin} onUnpin={sidePanel.unpin}
showPinButton={false} showPinButton={false}
side="left" side="left"
onDismiss={() => {}} onDismiss={() => {}}
@@ -275,52 +371,56 @@ export function App() {
{/* Browse Stat Block Panel (right) */} {/* Browse Stat Block Panel (right) */}
<StatBlockPanel <StatBlockPanel
creatureId={selectedCreatureId} creatureId={sidePanel.selectedCreatureId}
creature={selectedCreature} creature={selectedCreature}
isSourceCached={isSourceCached} isSourceCached={isSourceCached}
fetchAndCacheSource={fetchAndCacheSource} fetchAndCacheSource={fetchAndCacheSource}
uploadAndCacheSource={uploadAndCacheSource} uploadAndCacheSource={uploadAndCacheSource}
refreshCache={refreshCache} refreshCache={refreshCache}
panelRole="browse" panelRole="browse"
isFolded={isRightPanelFolded} isCollapsed={sidePanel.isRightPanelCollapsed}
onToggleFold={handleToggleFold} onToggleCollapse={sidePanel.toggleCollapse}
onPin={handlePin} onPin={sidePanel.togglePin}
onUnpin={() => {}} onUnpin={() => {}}
showPinButton={isWideDesktop && !!selectedCreature} showPinButton={sidePanel.isWideDesktop && !!selectedCreature}
side="right" side="right"
onDismiss={handleDismissBrowsePanel} onDismiss={sidePanel.dismissPanel}
bulkImportMode={bulkImportMode} bulkImportMode={sidePanel.bulkImportMode}
bulkImportState={bulkImport.state} bulkImportState={bulkImport.state}
onStartBulkImport={handleStartBulkImport} onStartBulkImport={handleStartBulkImport}
onBulkImportDone={handleBulkImportDone} onBulkImportDone={handleBulkImportDone}
sourceManagerMode={sidePanel.sourceManagerMode}
/> />
{/* Toast for bulk import progress when panel is closed */} <BulkImportToasts
{bulkImport.state.status === "loading" && !bulkImportMode && ( state={bulkImport.state}
visible={!sidePanel.bulkImportMode || sidePanel.isRightPanelCollapsed}
onReset={bulkImport.reset}
/>
{rollSkippedCount > 0 && (
<Toast <Toast
message={`Loading sources... ${bulkImport.state.completed + bulkImport.state.failed}/${bulkImport.state.total}`} message={`${rollSkippedCount} skipped — bestiary source not loaded`}
progress={ onDismiss={() => setRollSkippedCount(0)}
bulkImport.state.total > 0 autoDismissMs={4000}
? (bulkImport.state.completed + bulkImport.state.failed) /
bulkImport.state.total
: 0
}
onDismiss={() => {}}
/> />
)} )}
{bulkImport.state.status === "complete" && !bulkImportMode && (
{!!rollSingleSkipped && (
<Toast <Toast
message="All sources loaded" message="Can't roll — bestiary source not loaded"
onDismiss={bulkImport.reset} onDismiss={() => setRollSingleSkipped(false)}
autoDismissMs={3000} autoDismissMs={4000}
/> />
)} )}
{bulkImport.state.status === "partial-failure" && !bulkImportMode && (
<Toast <PlayerCharacterSection
message={`Loaded ${bulkImport.state.completed}/${bulkImport.state.total} sources (${bulkImport.state.failed} failed)`} ref={playerCharacterRef}
onDismiss={bulkImport.reset} characters={playerCharacters}
onCreateCharacter={createPlayerCharacter}
onEditCharacter={editPlayerCharacter}
onDeleteCharacter={deletePlayerCharacter}
/> />
)}
</div> </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";
// 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 />);
// 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 />);
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 />);
await addCombatant(user, "Ogre", { maxHp: "59" });
// Verify HP displays — currentHp and maxHp both show "59"
expect(screen.getByText("/")).toBeInTheDocument();
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(); const parentHandler = vi.fn();
render( render(
// biome-ignore lint/a11y/noStaticElementInteractions: test wrapper // biome-ignore lint/a11y/noStaticElementInteractions: test wrapper
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: test wrapper
<div onKeyDown={parentHandler}> <div onKeyDown={parentHandler}>
<ConfirmButton <ConfirmButton
icon={<XIcon />} icon={<XIcon />}

View File

@@ -6,6 +6,9 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StatBlockPanel } from "../components/stat-block-panel"; import { StatBlockPanel } from "../components/stat-block-panel";
const CLOSE_REGEX = /close/i;
const COLLAPSE_REGEX = /collapse/i;
const CREATURE_ID = "srd:goblin" as CreatureId; const CREATURE_ID = "srd:goblin" as CreatureId;
const CREATURE: Creature = { const CREATURE: Creature = {
id: CREATURE_ID, id: CREATURE_ID,
@@ -26,7 +29,7 @@ const CREATURE: Creature = {
}; };
function mockMatchMedia(matches: boolean) { function mockMatchMedia(matches: boolean) {
Object.defineProperty(window, "matchMedia", { Object.defineProperty(globalThis, "matchMedia", {
writable: true, writable: true,
value: vi.fn().mockImplementation((query: string) => ({ value: vi.fn().mockImplementation((query: string) => ({
matches, matches,
@@ -45,8 +48,8 @@ interface PanelProps {
creatureId?: CreatureId | null; creatureId?: CreatureId | null;
creature?: Creature | null; creature?: Creature | null;
panelRole?: "browse" | "pinned"; panelRole?: "browse" | "pinned";
isFolded?: boolean; isCollapsed?: boolean;
onToggleFold?: () => void; onToggleCollapse?: () => void;
onPin?: () => void; onPin?: () => void;
onUnpin?: () => void; onUnpin?: () => void;
showPinButton?: boolean; showPinButton?: boolean;
@@ -64,8 +67,8 @@ function renderPanel(overrides: PanelProps = {}) {
uploadAndCacheSource: vi.fn(), uploadAndCacheSource: vi.fn(),
refreshCache: vi.fn(), refreshCache: vi.fn(),
panelRole: "browse" as const, panelRole: "browse" as const,
isFolded: false, isCollapsed: false,
onToggleFold: vi.fn(), onToggleCollapse: vi.fn(),
onPin: vi.fn(), onPin: vi.fn(),
onUnpin: vi.fn(), onUnpin: vi.fn(),
showPinButton: false, showPinButton: false,
@@ -78,21 +81,21 @@ function renderPanel(overrides: PanelProps = {}) {
return props; return props;
} }
describe("Stat Block Panel Fold/Unfold and Pin", () => { describe("Stat Block Panel Collapse/Expand and Pin", () => {
beforeEach(() => { beforeEach(() => {
mockMatchMedia(true); // desktop by default mockMatchMedia(true); // desktop by default
}); });
afterEach(cleanup); afterEach(cleanup);
describe("US1: Fold and Unfold", () => { describe("US1: Collapse and Expand", () => {
it("shows fold button instead of close button on desktop", () => { it("shows collapse button instead of close button on desktop", () => {
renderPanel(); renderPanel();
expect( expect(
screen.getByRole("button", { name: "Fold stat block panel" }), screen.getByRole("button", { name: "Collapse stat block panel" }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.queryByRole("button", { name: /close/i }), screen.queryByRole("button", { name: CLOSE_REGEX }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
@@ -101,42 +104,42 @@ describe("Stat Block Panel Fold/Unfold and Pin", () => {
expect(screen.queryByText("Stat Block")).not.toBeInTheDocument(); expect(screen.queryByText("Stat Block")).not.toBeInTheDocument();
}); });
it("renders folded tab with creature name when isFolded is true", () => { it("renders collapsed tab with creature name when isCollapsed is true", () => {
renderPanel({ isFolded: true }); renderPanel({ isCollapsed: true });
expect(screen.getByText("Goblin")).toBeInTheDocument(); expect(screen.getByText("Goblin")).toBeInTheDocument();
expect( expect(
screen.getByRole("button", { name: "Unfold stat block panel" }), screen.getByRole("button", { name: "Expand stat block panel" }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("calls onToggleFold when fold button is clicked", () => { it("calls onToggleCollapse when collapse button is clicked", () => {
const props = renderPanel(); const props = renderPanel();
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "Fold stat block panel" }), screen.getByRole("button", { name: "Collapse stat block panel" }),
); );
expect(props.onToggleFold).toHaveBeenCalledTimes(1); expect(props.onToggleCollapse).toHaveBeenCalledTimes(1);
}); });
it("calls onToggleFold when folded tab is clicked", () => { it("calls onToggleCollapse when collapsed tab is clicked", () => {
const props = renderPanel({ isFolded: true }); const props = renderPanel({ isCollapsed: true });
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "Unfold stat block panel" }), screen.getByRole("button", { name: "Expand stat block panel" }),
); );
expect(props.onToggleFold).toHaveBeenCalledTimes(1); expect(props.onToggleCollapse).toHaveBeenCalledTimes(1);
}); });
it("applies translate-x class when folded (right side)", () => { it("applies translate-x class when collapsed (right side)", () => {
renderPanel({ isFolded: true, side: "right" }); renderPanel({ isCollapsed: true, side: "right" });
const panel = screen const panel = screen
.getByRole("button", { name: "Unfold stat block panel" }) .getByRole("button", { name: "Expand stat block panel" })
.closest("div"); .closest("div");
expect(panel?.className).toContain("translate-x-[calc(100%-40px)]"); expect(panel?.className).toContain("translate-x-[calc(100%-40px)]");
}); });
it("applies translate-x-0 when expanded", () => { it("applies translate-x-0 when expanded", () => {
renderPanel({ isFolded: false }); renderPanel({ isCollapsed: false });
const foldBtn = screen.getByRole("button", { const foldBtn = screen.getByRole("button", {
name: "Fold stat block panel", name: "Collapse stat block panel",
}); });
const panel = foldBtn.closest("div.fixed") as HTMLElement; const panel = foldBtn.closest("div.fixed") as HTMLElement;
expect(panel?.className).toContain("translate-x-0"); expect(panel?.className).toContain("translate-x-0");
@@ -148,12 +151,12 @@ describe("Stat Block Panel Fold/Unfold and Pin", () => {
mockMatchMedia(false); // mobile mockMatchMedia(false); // mobile
}); });
it("shows fold button instead of X close button on mobile drawer", () => { it("shows collapse button instead of X close button on mobile drawer", () => {
renderPanel(); renderPanel();
expect( expect(
screen.getByRole("button", { name: "Fold stat block panel" }), screen.getByRole("button", { name: "Collapse stat block panel" }),
).toBeInTheDocument(); ).toBeInTheDocument();
// No X close icon button — only backdrop dismiss and fold toggle // No X close icon button — only backdrop dismiss and collapse toggle
const buttons = screen.getAllByRole("button"); const buttons = screen.getAllByRole("button");
const buttonLabels = buttons.map((b) => b.getAttribute("aria-label")); const buttonLabels = buttons.map((b) => b.getAttribute("aria-label"));
expect(buttonLabels).not.toContain("Close"); expect(buttonLabels).not.toContain("Close");
@@ -175,8 +178,8 @@ describe("Stat Block Panel Fold/Unfold and Pin", () => {
uploadAndCacheSource={vi.fn()} uploadAndCacheSource={vi.fn()}
refreshCache={vi.fn()} refreshCache={vi.fn()}
panelRole="pinned" panelRole="pinned"
isFolded={false} isCollapsed={false}
onToggleFold={vi.fn()} onToggleCollapse={vi.fn()}
onPin={vi.fn()} onPin={vi.fn()}
onUnpin={vi.fn()} onUnpin={vi.fn()}
showPinButton={false} showPinButton={false}
@@ -235,7 +238,7 @@ describe("Stat Block Panel Fold/Unfold and Pin", () => {
it("positions browse panel on the right side", () => { it("positions browse panel on the right side", () => {
renderPanel({ panelRole: "browse", side: "right" }); renderPanel({ panelRole: "browse", side: "right" });
const foldBtn = screen.getByRole("button", { const foldBtn = screen.getByRole("button", {
name: "Fold stat block panel", name: "Collapse stat block panel",
}); });
const panel = foldBtn.closest("div.fixed") as HTMLElement; const panel = foldBtn.closest("div.fixed") as HTMLElement;
expect(panel?.className).toContain("right-0"); expect(panel?.className).toContain("right-0");
@@ -243,16 +246,16 @@ describe("Stat Block Panel Fold/Unfold and Pin", () => {
}); });
}); });
describe("US3: Fold independence with pinned panel", () => { describe("US3: Collapse independence with pinned panel", () => {
it("pinned panel has no fold button", () => { it("pinned panel has no collapse button", () => {
renderPanel({ panelRole: "pinned", side: "left" }); renderPanel({ panelRole: "pinned", side: "left" });
expect( expect(
screen.queryByRole("button", { name: /fold/i }), screen.queryByRole("button", { name: COLLAPSE_REGEX }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("pinned panel is always expanded (no translate offset)", () => { it("pinned panel is always expanded (no translate offset)", () => {
renderPanel({ panelRole: "pinned", side: "left", isFolded: false }); renderPanel({ panelRole: "pinned", side: "left", isCollapsed: false });
const unpinBtn = screen.getByRole("button", { const unpinBtn = screen.getByRole("button", {
name: "Unpin creature", name: "Unpin creature",
}); });

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,88 @@
// @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 { ActionBar } from "../action-bar";
afterEach(cleanup);
const defaultProps = {
onAddCombatant: vi.fn(),
onAddFromBestiary: vi.fn(),
bestiarySearch: () => [],
bestiaryLoaded: false,
};
function renderBar(overrides: Partial<Parameters<typeof ActionBar>[0]> = {}) {
const props = { ...defaultProps, ...overrides };
return render(<ActionBar {...props} />);
}
describe("ActionBar", () => {
it("renders input with placeholder '+ Add combatants'", () => {
renderBar();
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
});
it("submitting with a name calls onAddCombatant", async () => {
const user = userEvent.setup();
const onAddCombatant = vi.fn();
renderBar({ onAddCombatant });
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);
expect(onAddCombatant).toHaveBeenCalledWith("Goblin", undefined);
});
it("submitting with empty name does nothing", async () => {
const user = userEvent.setup();
const onAddCombatant = vi.fn();
renderBar({ onAddCombatant });
// Submit the form directly (Enter on empty input)
const input = screen.getByPlaceholderText("+ Add combatants");
await user.type(input, "{Enter}");
expect(onAddCombatant).not.toHaveBeenCalled();
});
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("shows roll all initiative button when showRollAllInitiative is true", () => {
const onRollAllInitiative = vi.fn();
renderBar({ showRollAllInitiative: true, onRollAllInitiative });
expect(
screen.getByRole("button", { name: "Roll all initiative" }),
).toBeInTheDocument();
});
it("roll all initiative button is disabled when rollAllInitiativeDisabled is true", () => {
const onRollAllInitiative = vi.fn();
renderBar({
showRollAllInitiative: true,
onRollAllInitiative,
rollAllInitiativeDisabled: true,
});
expect(
screen.getByRole("button", { name: "Roll all initiative" }),
).toBeDisabled();
});
});

View File

@@ -0,0 +1,164 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { combatantId } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CombatantRow } from "../combatant-row";
import { PLAYER_COLOR_HEX } from "../player-icon-map";
afterEach(cleanup);
const defaultProps = {
onRename: vi.fn(),
onSetInitiative: vi.fn(),
onRemove: vi.fn(),
onSetHp: vi.fn(),
onAdjustHp: vi.fn(),
onSetAc: vi.fn(),
onToggleCondition: vi.fn(),
onToggleConcentration: vi.fn(),
};
function renderRow(
overrides: Partial<{
combatant: Parameters<typeof CombatantRow>[0]["combatant"];
isActive: boolean;
onRollInitiative: (id: ReturnType<typeof combatantId>) => void;
onRemove: (id: ReturnType<typeof combatantId>) => void;
onShowStatBlock: () => void;
}> = {},
) {
const combatant = overrides.combatant ?? {
id: combatantId("1"),
name: "Goblin",
initiative: 15,
maxHp: 10,
currentHp: 10,
ac: 13,
};
const props = {
...defaultProps,
combatant,
isActive: overrides.isActive ?? false,
onRollInitiative: overrides.onRollInitiative,
onShowStatBlock: overrides.onShowStatBlock,
onRemove: overrides.onRemove ?? defaultProps.onRemove,
};
return render(<CombatantRow {...props} />);
}
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 '--' for current HP when no maxHp is set", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
},
});
expect(screen.getByLabelText("No HP set")).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 calls onRemove after confirmation", async () => {
const user = userEvent.setup();
const onRemove = vi.fn();
renderRow({ onRemove });
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);
expect(onRemove).toHaveBeenCalledWith(combatantId("1"));
});
it("shows d20 roll button when initiative is undefined and onRollInitiative is provided", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
},
onRollInitiative: vi.fn(),
});
expect(
screen.getByRole("button", { name: "Roll initiative" }),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,63 @@
// @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 { afterEach, describe, expect, it, vi } from "vitest";
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 result = render(
<ConditionPicker
activeConditions={overrides.activeConditions ?? []}
onToggle={onToggle}
onClose={onClose}
/>,
);
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,115 @@
// @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;
onClose: () => void;
}> = {},
) {
const onAdjust = overrides.onAdjust ?? vi.fn();
const onClose = overrides.onClose ?? vi.fn();
const result = render(
<HpAdjustPopover onAdjust={onAdjust} onClose={onClose} />,
);
return { ...result, onAdjust, 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");
});
});

View File

@@ -0,0 +1,127 @@
// @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(),
}));
import * as bestiaryCache from "../../adapters/bestiary-cache.js";
import { SourceManager } from "../source-manager";
const mockGetCachedSources = vi.mocked(bestiaryCache.getCachedSources);
const mockClearSource = vi.mocked(bestiaryCache.clearSource);
const mockClearAll = vi.mocked(bestiaryCache.clearAll);
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("SourceManager", () => {
it("shows 'No cached sources' empty state when no sources", async () => {
mockGetCachedSources.mockResolvedValue([]);
render(<SourceManager onCacheCleared={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("No cached sources")).toBeInTheDocument();
});
});
it("lists cached sources with display name and creature count", async () => {
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 onCacheCleared={vi.fn()} />);
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 onCacheCleared", async () => {
const user = userEvent.setup();
const onCacheCleared = vi.fn();
mockGetCachedSources
.mockResolvedValueOnce([
{
sourceCode: "mm",
displayName: "Monster Manual",
creatureCount: 300,
cachedAt: Date.now(),
},
])
.mockResolvedValue([]);
mockClearAll.mockResolvedValue(undefined);
render(<SourceManager onCacheCleared={onCacheCleared} />);
await waitFor(() => {
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "Clear All" }));
await waitFor(() => {
expect(mockClearAll).toHaveBeenCalled();
});
expect(onCacheCleared).toHaveBeenCalled();
});
it("individual source delete button calls clear for that source", async () => {
const user = userEvent.setup();
const onCacheCleared = vi.fn();
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 onCacheCleared={onCacheCleared} />);
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(onCacheCleared).toHaveBeenCalled();
});
});

View File

@@ -26,8 +26,6 @@ function renderNav(overrides: Partial<Encounter> = {}) {
onAdvanceTurn={vi.fn()} onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()} onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()} onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>, />,
); );
} }
@@ -54,11 +52,11 @@ describe("TurnNavigation", () => {
expect(container.textContent).not.toContain("—"); expect(container.textContent).not.toContain("—");
}); });
it("round badge and combatant name are in separate DOM elements", () => { it("round badge and combatant name are siblings in the center area", () => {
renderNav(); renderNav();
const badge = screen.getByText("R1"); const badge = screen.getByText("R1");
const name = screen.getByText("Goblin"); const name = screen.getByText("Goblin");
expect(badge.parentElement).not.toBe(name.parentElement); expect(badge.parentElement).toBe(name.parentElement);
}); });
it("updates the round badge when round changes", () => { it("updates the round badge when round changes", () => {
@@ -72,8 +70,6 @@ describe("TurnNavigation", () => {
onAdvanceTurn={vi.fn()} onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()} onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()} onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>, />,
); );
expect(screen.getByText("R2")).toBeInTheDocument(); expect(screen.getByText("R2")).toBeInTheDocument();
@@ -88,8 +84,6 @@ describe("TurnNavigation", () => {
onAdvanceTurn={vi.fn()} onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()} onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()} onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>, />,
); );
expect(screen.getByText("R3")).toBeInTheDocument(); expect(screen.getByText("R3")).toBeInTheDocument();
@@ -110,8 +104,6 @@ describe("TurnNavigation", () => {
onAdvanceTurn={vi.fn()} onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()} onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()} onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>, />,
); );
expect(screen.getByText("Goblin")).toBeInTheDocument(); expect(screen.getByText("Goblin")).toBeInTheDocument();
@@ -129,8 +121,6 @@ describe("TurnNavigation", () => {
onAdvanceTurn={vi.fn()} onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()} onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()} onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>, />,
); );
expect(screen.getByText("Conjurer")).toBeInTheDocument(); expect(screen.getByText("Conjurer")).toBeInTheDocument();
@@ -173,16 +163,6 @@ describe("TurnNavigation", () => {
expect( expect(
screen.getByRole("button", { name: "Next turn" }), screen.getByRole("button", { name: "Next turn" }),
).toBeInTheDocument(); ).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", () => { it("renders a 40-character name without truncation class issues", () => {

View File

@@ -12,7 +12,7 @@ export function AcShield({ value, onClick, className }: AcShieldProps) {
type="button" type="button"
onClick={onClick} onClick={onClick}
className={cn( 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, className,
)} )}
style={{ width: 28, height: 32 }} style={{ width: 28, height: 32 }}
@@ -29,8 +29,8 @@ export function AcShield({ value, onClick, className }: AcShieldProps) {
> >
<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" /> <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> </svg>
<span className="relative text-xs font-medium leading-none"> <span className="relative font-medium text-xs leading-none">
{value !== undefined ? value : "\u2014"} {value == null ? "\u2014" : String(value)}
</span> </span>
</button> </button>
); );

View File

@@ -1,8 +1,32 @@
import { Check, Eye, Import, Minus, Plus } from "lucide-react"; import type { PlayerCharacter, RollMode } from "@initiative/domain";
import { type FormEvent, useEffect, useRef, useState } from "react"; import {
Check,
Eye,
EyeOff,
Import,
Library,
Minus,
Monitor,
Moon,
Plus,
Sun,
Users,
} from "lucide-react";
import React, {
type RefObject,
useCallback,
useDeferredValue,
useState,
} from "react";
import type { SearchResult } from "../hooks/use-bestiary.js"; import type { SearchResult } from "../hooks/use-bestiary.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";
import { RollModeMenu } from "./roll-mode-menu.js";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js"; import { Input } from "./ui/input.js";
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
interface QueuedCreature { interface QueuedCreature {
result: SearchResult; result: SearchResult;
@@ -20,12 +44,246 @@ interface ActionBarProps {
onViewStatBlock?: (result: SearchResult) => void; onViewStatBlock?: (result: SearchResult) => void;
onBulkImport?: () => void; onBulkImport?: () => void;
bulkImportDisabled?: boolean; bulkImportDisabled?: boolean;
inputRef?: RefObject<HTMLInputElement | null>;
playerCharacters?: readonly PlayerCharacter[];
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
onManagePlayers?: () => void;
onRollAllInitiative?: (mode?: RollMode) => void;
showRollAllInitiative?: boolean;
rollAllInitiativeDisabled?: boolean;
onOpenSourceManager?: () => void;
autoFocus?: boolean;
themePreference?: "system" | "light" | "dark";
onCycleTheme?: () => void;
} }
function creatureKey(r: SearchResult): string { function creatureKey(r: SearchResult): string {
return `${r.source}:${r.name}`; return `${r.source}:${r.name}`;
} }
function AddModeSuggestions({
nameInput,
suggestions,
pcMatches,
suggestionIndex,
queued,
onDismiss,
onClickSuggestion,
onSetSuggestionIndex,
onSetQueued,
onConfirmQueued,
onAddFromPlayerCharacter,
onClear,
}: Readonly<{
nameInput: string;
suggestions: SearchResult[];
pcMatches: PlayerCharacter[];
suggestionIndex: number;
queued: QueuedCreature | null;
onDismiss: () => void;
onClear: () => void;
onClickSuggestion: (result: SearchResult) => void;
onSetSuggestionIndex: (i: number) => void;
onSetQueued: (q: QueuedCreature | null) => void;
onConfirmQueued: () => void;
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
}>) {
return (
<div className="card-glow absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-lg border border-border bg-card">
<button
type="button"
className="flex w-full items-center gap-1.5 border-border border-b px-3 py-2 text-left text-accent text-sm hover:bg-accent/20"
onMouseDown={(e) => e.preventDefault()}
onClick={onDismiss}
>
<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={() => {
onAddFromPlayerCharacter?.(pc);
onClear();
}}
>
{!!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={() => onClickSuggestion(result)}
onMouseEnter={() => onSetSuggestionIndex(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) {
onSetQueued(null);
} else {
onSetQueued({
...queued,
count: queued.count - 1,
});
}
}}
>
<Minus className="h-3 w-3" />
</button>
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-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();
onSetQueued({
...queued,
count: queued.count + 1,
});
}}
>
<Plus className="h-3 w-3" />
</button>
<button
type="button"
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
onConfirmQueued();
}}
>
<Check className="h-3.5 w-3.5" />
</button>
</>
) : (
result.sourceDisplayName
)}
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
</div>
);
}
const THEME_ICONS = {
system: Monitor,
light: Sun,
dark: Moon,
} as const;
const THEME_LABELS = {
system: "Theme: System",
light: "Theme: Light",
dark: "Theme: Dark",
} as const;
function buildOverflowItems(opts: {
onManagePlayers?: () => void;
onOpenSourceManager?: () => void;
bestiaryLoaded: boolean;
onBulkImport?: () => void;
bulkImportDisabled?: boolean;
themePreference?: "system" | "light" | "dark";
onCycleTheme?: () => 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.onCycleTheme) {
const pref = opts.themePreference ?? "system";
const ThemeIcon = THEME_ICONS[pref];
items.push({
icon: <ThemeIcon className="h-4 w-4" />,
label: THEME_LABELS[pref],
onClick: opts.onCycleTheme,
keepOpen: true,
});
}
return items;
}
export function ActionBar({ export function ActionBar({
onAddCombatant, onAddCombatant,
onAddFromBestiary, onAddFromBestiary,
@@ -34,22 +292,29 @@ export function ActionBar({
onViewStatBlock, onViewStatBlock,
onBulkImport, onBulkImport,
bulkImportDisabled, bulkImportDisabled,
}: ActionBarProps) { inputRef,
playerCharacters,
onAddFromPlayerCharacter,
onManagePlayers,
onRollAllInitiative,
showRollAllInitiative,
rollAllInitiativeDisabled,
onOpenSourceManager,
autoFocus,
themePreference,
onCycleTheme,
}: Readonly<ActionBarProps>) {
const [nameInput, setNameInput] = useState(""); const [nameInput, setNameInput] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]); const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
const deferredSuggestions = useDeferredValue(suggestions);
const deferredPcMatches = useDeferredValue(pcMatches);
const [suggestionIndex, setSuggestionIndex] = useState(-1); const [suggestionIndex, setSuggestionIndex] = useState(-1);
const [queued, setQueued] = useState<QueuedCreature | null>(null); const [queued, setQueued] = useState<QueuedCreature | null>(null);
const [customInit, setCustomInit] = useState(""); const [customInit, setCustomInit] = useState("");
const [customAc, setCustomAc] = useState(""); const [customAc, setCustomAc] = useState("");
const [customMaxHp, setCustomMaxHp] = useState(""); const [customMaxHp, setCustomMaxHp] = useState("");
const [browseMode, setBrowseMode] = useState(false);
// Stat block viewer: separate dropdown
const [viewerOpen, setViewerOpen] = useState(false);
const [viewerQuery, setViewerQuery] = useState("");
const [viewerResults, setViewerResults] = useState<SearchResult[]>([]);
const [viewerIndex, setViewerIndex] = useState(-1);
const viewerRef = useRef<HTMLDivElement>(null);
const viewerInputRef = useRef<HTMLInputElement>(null);
const clearCustomFields = () => { const clearCustomFields = () => {
setCustomInit(""); setCustomInit("");
@@ -57,15 +322,27 @@ export function ActionBar({
setCustomMaxHp(""); setCustomMaxHp("");
}; };
const clearInput = () => {
setNameInput("");
setSuggestions([]);
setPcMatches([]);
setQueued(null);
setSuggestionIndex(-1);
};
const dismissSuggestions = () => {
setSuggestions([]);
setPcMatches([]);
setQueued(null);
setSuggestionIndex(-1);
};
const confirmQueued = () => { const confirmQueued = () => {
if (!queued) return; if (!queued) return;
for (let i = 0; i < queued.count; i++) { for (let i = 0; i < queued.count; i++) {
onAddFromBestiary(queued.result); onAddFromBestiary(queued.result);
} }
setQueued(null); clearInput();
setNameInput("");
setSuggestions([]);
setSuggestionIndex(-1);
}; };
const parseNum = (v: string): number | undefined => { const parseNum = (v: string): number | undefined => {
@@ -74,8 +351,9 @@ export function ActionBar({
return Number.isNaN(n) ? undefined : n; return Number.isNaN(n) ? undefined : n;
}; };
const handleAdd = (e: FormEvent) => { const handleAdd = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
if (browseMode) return;
if (queued) { if (queued) {
confirmQueued(); confirmQueued();
return; return;
@@ -91,20 +369,32 @@ export function ActionBar({
onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined); onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
setNameInput(""); setNameInput("");
setSuggestions([]); setSuggestions([]);
setPcMatches([]);
clearCustomFields(); clearCustomFields();
}; };
const handleNameChange = (value: string) => { const handleBrowseSearch = (value: string) => {
setNameInput(value); setSuggestions(value.length >= 2 ? bestiarySearch(value) : []);
setSuggestionIndex(-1); };
const handleAddSearch = (value: string) => {
let newSuggestions: SearchResult[] = []; let newSuggestions: SearchResult[] = [];
let newPcMatches: PlayerCharacter[] = [];
if (value.length >= 2) { if (value.length >= 2) {
newSuggestions = bestiarySearch(value); newSuggestions = bestiarySearch(value);
setSuggestions(newSuggestions); setSuggestions(newSuggestions);
if (playerCharacters && playerCharacters.length > 0) {
const lower = value.toLowerCase();
newPcMatches = playerCharacters.filter((pc) =>
pc.name.toLowerCase().includes(lower),
);
}
setPcMatches(newPcMatches);
} else { } else {
setSuggestions([]); setSuggestions([]);
setPcMatches([]);
} }
if (newSuggestions.length > 0) { if (newSuggestions.length > 0 || newPcMatches.length > 0) {
clearCustomFields(); clearCustomFields();
} }
if (queued) { if (queued) {
@@ -116,6 +406,16 @@ export function ActionBar({
} }
}; };
const handleNameChange = (value: string) => {
setNameInput(value);
setSuggestionIndex(-1);
if (browseMode) {
handleBrowseSearch(value);
} else {
handleAddSearch(value);
}
};
const handleClickSuggestion = (result: SearchResult) => { const handleClickSuggestion = (result: SearchResult) => {
const key = creatureKey(result); const key = creatureKey(result);
if (queued && creatureKey(queued.result) === key) { if (queued && creatureKey(queued.result) === key) {
@@ -133,8 +433,11 @@ export function ActionBar({
} }
}; };
const hasSuggestions =
deferredSuggestions.length > 0 || deferredPcMatches.length > 0;
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (suggestions.length === 0) return; if (!hasSuggestions) return;
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
@@ -146,175 +449,159 @@ export function ActionBar({
e.preventDefault(); e.preventDefault();
handleEnter(); handleEnter();
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
setQueued(null); dismissSuggestions();
setSuggestionIndex(-1);
setSuggestions([]);
} }
}; };
// Stat block viewer dropdown handlers const handleBrowseKeyDown = (e: React.KeyboardEvent) => {
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") { if (e.key === "Escape") {
closeViewer(); setBrowseMode(false);
clearInput();
return; return;
} }
if (viewerResults.length === 0) return; if (suggestions.length === 0) return;
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
setViewerIndex((i) => (i < viewerResults.length - 1 ? i + 1 : 0)); setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
} else if (e.key === "ArrowUp") { } else if (e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
setViewerIndex((i) => (i > 0 ? i - 1 : viewerResults.length - 1)); setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
} else if (e.key === "Enter" && viewerIndex >= 0) { } else if (e.key === "Enter" && suggestionIndex >= 0) {
e.preventDefault(); e.preventDefault();
handleViewerSelect(viewerResults[viewerIndex]); onViewStatBlock?.(suggestions[suggestionIndex]);
setBrowseMode(false);
clearInput();
} }
}; };
// Close viewer on outside click const handleBrowseSelect = (result: SearchResult) => {
useEffect(() => { onViewStatBlock?.(result);
if (!viewerOpen) return; setBrowseMode(false);
function handleClickOutside(e: MouseEvent) { clearInput();
if (viewerRef.current && !viewerRef.current.contains(e.target as Node)) { };
closeViewer();
} const toggleBrowseMode = () => {
} setBrowseMode((m) => !m);
document.addEventListener("mousedown", handleClickOutside); clearInput();
return () => document.removeEventListener("mousedown", handleClickOutside); clearCustomFields();
}, [viewerOpen]); };
const [rollAllMenuPos, setRollAllMenuPos] = useState<{
x: number;
y: number;
} | null>(null);
const openRollAllMenu = useCallback((x: number, y: number) => {
setRollAllMenuPos({ x, y });
}, []);
const rollAllLongPress = useLongPress(
useCallback(
(e: React.TouchEvent) => {
const touch = e.touches[0];
if (touch) openRollAllMenu(touch.clientX, touch.clientY);
},
[openRollAllMenu],
),
);
const overflowItems = buildOverflowItems({
onManagePlayers,
onOpenSourceManager,
bestiaryLoaded,
onBulkImport,
bulkImportDisabled,
themePreference,
onCycleTheme,
});
return ( 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 rounded-lg border border-border bg-card px-4 py-3">
<form <form
onSubmit={handleAdd} onSubmit={handleAdd}
className="relative flex flex-1 items-center gap-2" className="relative flex flex-1 items-center gap-2"
> >
<div className="relative flex-1"> <div className="flex-1">
<div className="relative max-w-xs">
<Input <Input
ref={inputRef}
type="text" type="text"
value={nameInput} value={nameInput}
onChange={(e) => handleNameChange(e.target.value)} onChange={(e) => handleNameChange(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={browseMode ? handleBrowseKeyDown : handleKeyDown}
placeholder="+ Add combatants" placeholder={
className="max-w-xs" browseMode ? "Search stat blocks..." : "+ Add combatants"
}
className="pr-8"
autoFocus={autoFocus}
/> />
{suggestions.length > 0 && ( {bestiaryLoaded && !!onViewStatBlock && (
<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 <button
type="button" type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${ tabIndex={-1}
isQueued className={cn(
? "bg-accent/30 text-foreground" "absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
: i === suggestionIndex browseMode && "text-accent",
)}
onClick={toggleBrowseMode}
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
aria-label={
browseMode ? "Switch to add mode" : "Browse stat blocks"
}
>
{browseMode ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
)}
{browseMode && deferredSuggestions.length > 0 && (
<div className="card-glow absolute bottom-full z-50 mb-1 w-full rounded-lg border border-border bg-card">
<ul className="max-h-48 overflow-y-auto py-1">
{deferredSuggestions.map((result, i) => (
<li key={creatureKey(result)}>
<button
type="button"
className={cn(
"flex w-full items-center justify-between px-3 py-1.5 text-left text-sm",
i === suggestionIndex
? "bg-accent/20 text-foreground" ? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg" : "text-foreground hover:bg-hover-neutral-bg",
}`} )}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => handleClickSuggestion(result)} onClick={() => handleBrowseSelect(result)}
onMouseEnter={() => setSuggestionIndex(i)} onMouseEnter={() => setSuggestionIndex(i)}
> >
<span>{result.name}</span> <span>{result.name}</span>
<span className="flex items-center gap-1 text-xs text-muted-foreground"> <span className="text-muted-foreground text-xs">
{isQueued ? ( {result.sourceDisplayName}
<>
<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> </span>
</button> </button>
</li> </li>
); ))}
})}
</ul> </ul>
</div> </div>
)} )}
{!browseMode && hasSuggestions && (
<AddModeSuggestions
nameInput={nameInput}
suggestions={deferredSuggestions}
pcMatches={deferredPcMatches}
suggestionIndex={suggestionIndex}
queued={queued}
onDismiss={dismissSuggestions}
onClear={clearInput}
onClickSuggestion={handleClickSuggestion}
onSetSuggestionIndex={setSuggestionIndex}
onSetQueued={setQueued}
onConfirmQueued={confirmQueued}
onAddFromPlayerCharacter={onAddFromPlayerCharacter}
/>
)}
</div> </div>
{nameInput.length >= 2 && suggestions.length === 0 && ( </div>
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
type="text" type="text"
@@ -342,75 +629,38 @@ export function ActionBar({
/> />
</div> </div>
)} )}
<Button type="submit" size="sm"> {!browseMode && nameInput.length >= 2 && !hasSuggestions && (
Add <Button type="submit">Add</Button>
</Button> )}
{bestiaryLoaded && onViewStatBlock && ( {showRollAllInitiative && !!onRollAllInitiative && (
<div ref={viewerRef} className="relative"> <>
<Button <Button
type="button" type="button"
size="sm" size="icon"
variant="ghost" variant="ghost"
onClick={() => (viewerOpen ? closeViewer() : openViewer())} className="text-muted-foreground hover:text-hover-action"
onClick={() => onRollAllInitiative()}
onContextMenu={(e) => {
e.preventDefault();
openRollAllMenu(e.clientX, e.clientY);
}}
{...rollAllLongPress}
disabled={rollAllInitiativeDisabled}
title="Roll all initiative"
aria-label="Roll all initiative"
> >
<Eye className="h-4 w-4" /> <D20Icon className="h-6 w-6" />
</Button> </Button>
{viewerOpen && ( {!!rollAllMenuPos && (
<div className="absolute bottom-full right-0 z-50 mb-1 w-64 rounded-md border border-border bg-card shadow-lg"> <RollModeMenu
<div className="p-2"> position={rollAllMenuPos}
<Input onSelect={(mode) => onRollAllInitiative(mode)}
ref={viewerInputRef} onClose={() => setRollAllMenuPos(null)}
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>
)} )}
{viewerQuery.length >= 2 && viewerResults.length === 0 && ( </>
<div className="border-t border-border px-3 py-2 text-sm text-muted-foreground">
No creatures found
</div>
)}
</div>
)}
</div>
)}
{bestiaryLoaded && onBulkImport && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={onBulkImport}
disabled={bulkImportDisabled}
>
<Import className="h-4 w-4" />
</Button>
)} )}
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
</form> </form>
</div> </div>
); );

View File

@@ -1,5 +1,5 @@
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useState } from "react"; import { useId, useState } from "react";
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js"; import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js";
import type { BulkImportState } from "../hooks/use-bulk-import.js"; import type { BulkImportState } from "../hooks/use-bulk-import.js";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
@@ -18,19 +18,18 @@ export function BulkImportPrompt({
importState, importState,
onStartImport, onStartImport,
onDone, onDone,
}: BulkImportPromptProps) { }: Readonly<BulkImportPromptProps>) {
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL); const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
const baseUrlId = useId();
const totalSources = getAllSourceCodes().length; const totalSources = getAllSourceCodes().length;
if (importState.status === "complete") { if (importState.status === "complete") {
return ( return (
<div className="flex flex-col gap-4"> <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 All sources loaded
</div> </div>
<Button size="sm" onClick={onDone}> <Button onClick={onDone}>Done</Button>
Done
</Button>
</div> </div>
); );
} }
@@ -42,9 +41,7 @@ export function BulkImportPrompt({
Loaded {importState.completed}/{importState.total} sources ( Loaded {importState.completed}/{importState.total} sources (
{importState.failed} failed) {importState.failed} failed)
</div> </div>
<Button size="sm" onClick={onDone}> <Button onClick={onDone}>Done</Button>
Done
</Button>
</div> </div>
); );
} }
@@ -58,7 +55,7 @@ export function BulkImportPrompt({
return ( return (
<div className="flex flex-col gap-4"> <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" /> <Loader2 className="h-4 w-4 animate-spin" />
Loading sources... {processed}/{importState.total} Loading sources... {processed}/{importState.total}
</div> </div>
@@ -78,24 +75,20 @@ export function BulkImportPrompt({
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div> <div>
<h3 className="text-sm font-semibold text-foreground"> <h3 className="font-semibold text-foreground text-sm">
Bulk Import Sources Import All Sources
</h3> </h3>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-muted-foreground text-xs">
Load stat block data for all {totalSources} sources at once. This will Load stat block data for all {totalSources} sources at once.
download approximately 12.5 MB of data.
</p> </p>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label <label htmlFor={baseUrlId} className="text-muted-foreground text-xs">
htmlFor="bulk-base-url"
className="text-xs text-muted-foreground"
>
Base URL Base URL
</label> </label>
<Input <Input
id="bulk-base-url" id={baseUrlId}
type="url" type="url"
value={baseUrl} value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)} onChange={(e) => setBaseUrl(e.target.value)}
@@ -103,11 +96,7 @@ export function BulkImportPrompt({
/> />
</div> </div>
<Button <Button onClick={() => onStartImport(baseUrl)} disabled={isDisabled}>
size="sm"
onClick={() => onStartImport(baseUrl)}
disabled={isDisabled}
>
Load All Load All
</Button> </Button>
</div> </div>

View File

@@ -0,0 +1,49 @@
import type { BulkImportState } from "../hooks/use-bulk-import.js";
import { Toast } from "./toast.js";
interface BulkImportToastsProps {
state: BulkImportState;
visible: boolean;
onReset: () => void;
}
export function BulkImportToasts({
state,
visible,
onReset,
}: Readonly<BulkImportToastsProps>) {
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={onReset}
autoDismissMs={3000}
/>
);
}
if (state.status === "partial-failure") {
return (
<Toast
message={`Loaded ${state.completed}/${state.total} sources (${state.failed} failed)`}
onDismiss={onReset}
/>
);
}
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

@@ -2,15 +2,20 @@ import {
type CombatantId, type CombatantId,
type ConditionId, type ConditionId,
deriveHpStatus, deriveHpStatus,
type PlayerIcon,
type RollMode,
} from "@initiative/domain"; } from "@initiative/domain";
import { Brain, X } from "lucide-react"; import { Book, BookOpen, Brain, X } from "lucide-react";
import { type Ref, useCallback, useEffect, useRef, useState } from "react"; import { type Ref, useCallback, useEffect, useRef, useState } from "react";
import { useLongPress } from "../hooks/use-long-press";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { AcShield } from "./ac-shield"; import { AcShield } from "./ac-shield";
import { ConditionPicker } from "./condition-picker"; import { ConditionPicker } from "./condition-picker";
import { ConditionTags } from "./condition-tags"; import { ConditionTags } from "./condition-tags";
import { D20Icon } from "./d20-icon"; import { D20Icon } from "./d20-icon";
import { HpAdjustPopover } from "./hp-adjust-popover"; import { HpAdjustPopover } from "./hp-adjust-popover";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
import { RollModeMenu } from "./roll-mode-menu";
import { ConfirmButton } from "./ui/confirm-button"; import { ConfirmButton } from "./ui/confirm-button";
import { Input } from "./ui/input"; import { Input } from "./ui/input";
@@ -23,6 +28,8 @@ interface Combatant {
readonly ac?: number; readonly ac?: number;
readonly conditions?: readonly ConditionId[]; readonly conditions?: readonly ConditionId[];
readonly isConcentrating?: boolean; readonly isConcentrating?: boolean;
readonly color?: string;
readonly icon?: string;
} }
interface CombatantRowProps { interface CombatantRowProps {
@@ -37,26 +44,24 @@ interface CombatantRowProps {
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void; onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
onToggleConcentration: (id: CombatantId) => void; onToggleConcentration: (id: CombatantId) => void;
onShowStatBlock?: () => void; onShowStatBlock?: () => void;
onRollInitiative?: (id: CombatantId) => void; isStatBlockOpen?: boolean;
onRollInitiative?: (id: CombatantId, mode?: RollMode) => void;
} }
function EditableName({ function EditableName({
name, name,
combatantId, combatantId,
onRename, onRename,
onShowStatBlock, color,
}: { }: Readonly<{
name: string; name: string;
combatantId: CombatantId; combatantId: CombatantId;
onRename: (id: CombatantId, newName: string) => void; onRename: (id: CombatantId, newName: string) => void;
onShowStatBlock?: () => void; color?: string;
}) { }>) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(name); const [draft, setDraft] = useState(name);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const longPressTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const longPressTriggeredRef = useRef(false);
const commit = useCallback(() => { const commit = useCallback(() => {
const trimmed = draft.trim(); const trimmed = draft.trim();
@@ -72,53 +77,13 @@ function EditableName({
requestAnimationFrame(() => inputRef.current?.select()); requestAnimationFrame(() => inputRef.current?.select());
}, [name]); }, [name]);
useEffect(() => {
return () => {
clearTimeout(clickTimerRef.current);
clearTimeout(longPressTimerRef.current);
};
}, []);
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (longPressTriggeredRef.current) {
longPressTriggeredRef.current = false;
return;
}
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
clickTimerRef.current = undefined;
startEditing();
} else {
clickTimerRef.current = setTimeout(() => {
clickTimerRef.current = undefined;
onShowStatBlock?.();
}, 250);
}
},
[startEditing, onShowStatBlock],
);
const handleTouchStart = useCallback(() => {
longPressTriggeredRef.current = false;
longPressTimerRef.current = setTimeout(() => {
longPressTriggeredRef.current = true;
startEditing();
}, 500);
}, [startEditing]);
const cancelLongPress = useCallback(() => {
clearTimeout(longPressTimerRef.current);
}, []);
if (editing) { if (editing) {
return ( return (
<Input <Input
ref={inputRef} ref={inputRef}
type="text" type="text"
value={draft} value={draft}
className="h-7 text-sm" className="h-7 max-w-48 text-sm"
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
onBlur={commit} onBlur={commit}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -130,29 +95,24 @@ function EditableName({
} }
return ( return (
<>
<button <button
type="button" type="button"
onClick={handleClick} onClick={startEditing}
onTouchStart={handleTouchStart} className="cursor-text truncate text-left text-foreground text-sm transition-colors hover:text-hover-neutral"
onTouchEnd={cancelLongPress} style={color ? { color } : undefined}
onTouchCancel={cancelLongPress}
onTouchMove={cancelLongPress}
className="truncate text-left text-sm text-foreground cursor-text hover:text-hover-neutral transition-colors"
> >
{name} {name}
</button> </button>
</>
); );
} }
function MaxHpDisplay({ function MaxHpDisplay({
maxHp, maxHp,
onCommit, onCommit,
}: { }: Readonly<{
maxHp: number | undefined; maxHp: number | undefined;
onCommit: (value: number | undefined) => void; onCommit: (value: number | undefined) => void;
}) { }>) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(maxHp?.toString() ?? ""); const [draft, setDraft] = useState(maxHp?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -198,7 +158,7 @@ function MaxHpDisplay({
<button <button
type="button" type="button"
onClick={startEditing} 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="inline-block h-7 min-w-[3ch] text-center text-muted-foreground text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral"
> >
{maxHp ?? "Max"} {maxHp ?? "Max"}
</button> </button>
@@ -210,12 +170,12 @@ function ClickableHp({
maxHp, maxHp,
onAdjust, onAdjust,
dimmed, dimmed,
}: { }: Readonly<{
currentHp: number | undefined; currentHp: number | undefined;
maxHp: number | undefined; maxHp: number | undefined;
onAdjust: (delta: number) => void; onAdjust: (delta: number) => void;
dimmed?: boolean; dimmed?: boolean;
}) { }>) {
const [popoverOpen, setPopoverOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false);
const status = deriveHpStatus(currentHp, maxHp); const status = deriveHpStatus(currentHp, maxHp);
@@ -223,9 +183,11 @@ function ClickableHp({
return ( return (
<span <span
className={cn( className={cn(
"inline-block h-7 w-[4ch] text-center text-sm leading-7 tabular-nums text-muted-foreground", "inline-block h-7 w-[4ch] text-center text-muted-foreground text-sm tabular-nums leading-7",
dimmed && "opacity-50", dimmed && "opacity-50",
)} )}
role="status"
aria-label="No HP set"
> >
-- --
</span> </span>
@@ -237,8 +199,9 @@ function ClickableHp({
<button <button
type="button" type="button"
onClick={() => setPopoverOpen(true)} onClick={() => setPopoverOpen(true)}
aria-label={`Current HP: ${currentHp} (${status})`}
className={cn( 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 tabular-nums leading-7 transition-colors hover:text-hover-neutral",
status === "bloodied" && "text-amber-400", status === "bloodied" && "text-amber-400",
status === "unconscious" && "text-red-400", status === "unconscious" && "text-red-400",
status === "healthy" && "text-foreground", status === "healthy" && "text-foreground",
@@ -247,7 +210,7 @@ function ClickableHp({
> >
{currentHp} {currentHp}
</button> </button>
{popoverOpen && ( {!!popoverOpen && (
<HpAdjustPopover <HpAdjustPopover
onAdjust={onAdjust} onAdjust={onAdjust}
onClose={() => setPopoverOpen(false)} onClose={() => setPopoverOpen(false)}
@@ -260,10 +223,10 @@ function ClickableHp({
function AcDisplay({ function AcDisplay({
ac, ac,
onCommit, onCommit,
}: { }: Readonly<{
ac: number | undefined; ac: number | undefined;
onCommit: (value: number | undefined) => void; onCommit: (value: number | undefined) => void;
}) { }>) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(ac?.toString() ?? ""); const [draft, setDraft] = useState(ac?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -314,16 +277,34 @@ function InitiativeDisplay({
dimmed, dimmed,
onSetInitiative, onSetInitiative,
onRollInitiative, onRollInitiative,
}: { }: Readonly<{
initiative: number | undefined; initiative: number | undefined;
combatantId: CombatantId; combatantId: CombatantId;
dimmed: boolean; dimmed: boolean;
onSetInitiative: (id: CombatantId, value: number | undefined) => void; onSetInitiative: (id: CombatantId, value: number | undefined) => void;
onRollInitiative?: (id: CombatantId) => void; onRollInitiative?: (id: CombatantId, mode?: RollMode) => void;
}) { }>) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(initiative?.toString() ?? ""); const [draft, setDraft] = useState(initiative?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null); 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(() => { const commit = useCallback(() => {
if (draft === "") { if (draft === "") {
@@ -368,9 +349,15 @@ function InitiativeDisplay({
// Empty + bestiary creature → d20 roll button // Empty + bestiary creature → d20 roll button
if (initiative === undefined && onRollInitiative) { if (initiative === undefined && onRollInitiative) {
return ( return (
<>
<button <button
type="button" type="button"
onClick={() => onRollInitiative(combatantId)} onClick={() => onRollInitiative(combatantId)}
onContextMenu={(e) => {
e.preventDefault();
openMenu(e.clientX, e.clientY);
}}
{...longPress}
className={cn( className={cn(
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral", "flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
dimmed && "opacity-50", dimmed && "opacity-50",
@@ -380,6 +367,14 @@ function InitiativeDisplay({
> >
<D20Icon className="h-7 w-7" /> <D20Icon className="h-7 w-7" />
</button> </button>
{!!menuPos && (
<RollModeMenu
position={menuPos}
onSelect={(mode) => onRollInitiative(combatantId, mode)}
onClose={() => setMenuPos(null)}
/>
)}
</>
); );
} }
@@ -390,10 +385,10 @@ function InitiativeDisplay({
type="button" type="button"
onClick={startEditing} onClick={startEditing}
className={cn( className={cn(
"h-7 w-full text-center text-sm leading-7 tabular-nums transition-colors", "h-7 w-full text-center text-sm tabular-nums leading-7 transition-colors",
initiative !== undefined initiative === undefined
? "font-medium text-foreground hover:text-hover-neutral" ? "text-muted-foreground hover:text-hover-neutral"
: "text-muted-foreground hover:text-hover-neutral", : "font-medium text-foreground hover:text-hover-neutral",
dimmed && "opacity-50", dimmed && "opacity-50",
)} )}
> >
@@ -406,9 +401,13 @@ function rowBorderClass(
isActive: boolean, isActive: boolean,
isConcentrating: boolean | undefined, isConcentrating: boolean | undefined,
): string { ): string {
if (isActive) return "border-l-2 border-l-accent bg-accent/10"; if (isActive && isConcentrating)
if (isConcentrating) return "border-l-2 border-l-purple-400"; return "border border-l-2 border-active-row-border border-l-purple-400 bg-active-row-bg card-glow";
return "border-l-2 border-l-transparent"; 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( function concentrationIconClass(
@@ -420,17 +419,6 @@ function concentrationIconClass(
return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400"; return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400";
} }
function activateOnKeyDown(
handler: () => void,
): (e: { key: string; preventDefault: () => void }) => void {
return (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handler();
}
};
}
export function CombatantRow({ export function CombatantRow({
ref, ref,
combatant, combatant,
@@ -444,6 +432,7 @@ export function CombatantRow({
onToggleCondition, onToggleCondition,
onToggleConcentration, onToggleConcentration,
onShowStatBlock, onShowStatBlock,
isStatBlockOpen,
onRollInitiative, onRollInitiative,
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) { }: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
const { id, name, initiative, maxHp, currentHp } = combatant; const { id, name, initiative, maxHp, currentHp } = combatant;
@@ -478,35 +467,28 @@ export function CombatantRow({
} }
}, [combatant.isConcentrating]); }, [combatant.isConcentrating]);
const pcColor = combatant.color
? PLAYER_COLOR_HEX[combatant.color as keyof typeof PLAYER_COLOR_HEX]
: undefined;
return ( return (
/* biome-ignore lint/a11y/noStaticElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
<div <div
ref={ref} ref={ref}
role={onShowStatBlock ? "button" : undefined}
tabIndex={onShowStatBlock ? 0 : undefined}
className={cn( className={cn(
"group rounded-md pr-3 transition-colors", "group rounded-lg pr-3 transition-colors",
rowBorderClass(isActive, combatant.isConcentrating), rowBorderClass(isActive, combatant.isConcentrating),
isPulsing && "animate-concentration-pulse", 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_1fr_auto_auto_2rem] items-center gap-3 py-2">
{/* Concentration */} {/* Concentration */}
<button <button
type="button" type="button"
onClick={(e) => { onClick={() => onToggleConcentration(id)}
e.stopPropagation();
onToggleConcentration(id);
}}
title="Concentrating" title="Concentrating"
aria-label="Toggle concentration" aria-label="Toggle concentration"
className={cn( className={cn(
"flex w-full items-center justify-center self-stretch -my-2 -ml-[2px] pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100", "-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), concentrationIconClass(combatant.isConcentrating, dimmed),
)} )}
> >
@@ -514,11 +496,6 @@ export function CombatantRow({
</button> </button>
{/* Initiative */} {/* Initiative */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
<div
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<InitiativeDisplay <InitiativeDisplay
initiative={initiative} initiative={initiative}
combatantId={id} combatantId={id}
@@ -526,27 +503,53 @@ export function CombatantRow({
onSetInitiative={onSetInitiative} onSetInitiative={onSetInitiative}
onRollInitiative={onRollInitiative} onRollInitiative={onRollInitiative}
/> />
</div>
{/* Name + Conditions */} {/* Name + Conditions */}
<div <div
className={cn( 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", dimmed && "opacity-50",
)} )}
> >
{!!onShowStatBlock && (
<button
type="button"
onClick={onShowStatBlock}
title="View stat block"
aria-label="View stat block"
className="shrink-0 text-foreground transition-colors hover:text-hover-neutral"
>
{isStatBlockOpen ? <BookOpen size={16} /> : <Book size={16} />}
</button>
)}
{!!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 <EditableName
name={name} name={name}
combatantId={id} combatantId={id}
onRename={onRename} onRename={onRename}
onShowStatBlock={onShowStatBlock} color={pcColor}
/> />
<ConditionTags <ConditionTags
conditions={combatant.conditions} conditions={combatant.conditions}
onRemove={(conditionId) => onToggleCondition(id, conditionId)} onRemove={(conditionId) => onToggleCondition(id, conditionId)}
onOpenPicker={() => setPickerOpen((prev) => !prev)} onOpenPicker={() => setPickerOpen((prev) => !prev)}
/> />
{pickerOpen && ( {!!pickerOpen && (
<ConditionPicker <ConditionPicker
activeConditions={combatant.conditions} activeConditions={combatant.conditions}
onToggle={(conditionId) => onToggleCondition(id, conditionId)} onToggle={(conditionId) => onToggleCondition(id, conditionId)}
@@ -556,22 +559,12 @@ export function CombatantRow({
</div> </div>
{/* AC */} {/* AC */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */} <div className={cn(dimmed && "opacity-50")}>
<div
className={cn(dimmed && "opacity-50")}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} /> <AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
</div> </div>
{/* HP */} {/* HP */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */} <div className="flex items-center gap-1">
<div
className="flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<ClickableHp <ClickableHp
currentHp={currentHp} currentHp={currentHp}
maxHp={maxHp} maxHp={maxHp}
@@ -581,7 +574,7 @@ export function CombatantRow({
{maxHp !== undefined && ( {maxHp !== undefined && (
<span <span
className={cn( className={cn(
"text-sm tabular-nums text-muted-foreground", "text-muted-foreground text-sm tabular-nums",
dimmed && "opacity-50", dimmed && "opacity-50",
)} )}
> >
@@ -598,7 +591,7 @@ export function CombatantRow({
icon={<X size={16} />} icon={<X size={16} />}
label="Remove combatant" label="Remove combatant"
onConfirm={() => onRemove(id)} 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" 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>
</div> </div>

View File

@@ -61,7 +61,7 @@ export function ConditionPicker({
activeConditions, activeConditions,
onToggle, onToggle,
onClose, onClose,
}: ConditionPickerProps) { }: Readonly<ConditionPickerProps>) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [flipped, setFlipped] = useState(false); const [flipped, setFlipped] = useState(false);
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined); const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
@@ -97,7 +97,7 @@ export function ConditionPicker({
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"absolute left-0 z-10 w-fit overflow-y-auto rounded-md border border-border bg-background p-1 shadow-lg", "card-glow absolute left-0 z-10 w-fit overflow-y-auto rounded-lg border border-border bg-background p-1",
flipped ? "bottom-full mb-1" : "top-full mt-1", flipped ? "bottom-full mb-1" : "top-full mt-1",
)} )}
style={maxHeight ? { maxHeight } : undefined} style={maxHeight ? { maxHeight } : undefined}

View File

@@ -18,6 +18,7 @@ import {
Sparkles, Sparkles,
ZapOff, ZapOff,
} from "lucide-react"; } from "lucide-react";
import { cn } from "../lib/utils.js";
const ICON_MAP: Record<string, LucideIcon> = { const ICON_MAP: Record<string, LucideIcon> = {
EyeOff, EyeOff,
@@ -60,7 +61,7 @@ export function ConditionTags({
conditions, conditions,
onRemove, onRemove,
onOpenPicker, onOpenPicker,
}: ConditionTagsProps) { }: Readonly<ConditionTagsProps>) {
return ( return (
<div className="flex flex-wrap items-center gap-0.5"> <div className="flex flex-wrap items-center gap-0.5">
{conditions?.map((condId) => { {conditions?.map((condId) => {
@@ -75,7 +76,10 @@ export function ConditionTags({
type="button" type="button"
title={def.label} title={def.label}
aria-label={`Remove ${def.label}`} aria-label={`Remove ${def.label}`}
className={`inline-flex items-center rounded p-0.5 hover:bg-hover-neutral-bg transition-colors ${colorClass}`} className={cn(
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
colorClass,
)}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onRemove(condId); onRemove(condId);
@@ -89,7 +93,7 @@ export function ConditionTags({
type="button" type="button"
title="Add condition" title="Add condition"
aria-label="Add condition" aria-label="Add condition"
className="inline-flex items-center rounded p-0.5 text-muted-foreground hover:text-hover-neutral hover:bg-hover-neutral-bg transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 pointer-coarse:opacity-100 transition-opacity" className="inline-flex items-center rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-colors transition-opacity hover:bg-hover-neutral-bg hover:text-hover-neutral focus:opacity-100 group-hover:opacity-100"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onOpenPicker(); 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

@@ -6,9 +6,10 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input"; import { Input } from "./ui/input";
const DIGITS_ONLY_REGEX = /^\d+$/;
interface HpAdjustPopoverProps { interface HpAdjustPopoverProps {
readonly onAdjust: (delta: number) => void; readonly onAdjust: (delta: number) => void;
readonly onClose: () => void; readonly onClose: () => void;
@@ -86,7 +87,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
return ( return (
<div <div
ref={ref} 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={ style={
pos pos
? { top: pos.top, left: pos.left } ? { top: pos.top, left: pos.left }
@@ -103,36 +104,32 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
className="h-7 w-[7ch] text-center text-sm tabular-nums" className="h-7 w-[7ch] text-center text-sm tabular-nums"
onChange={(e) => { onChange={(e) => {
const v = e.target.value; const v = e.target.value;
if (v === "" || /^\d+$/.test(v)) { if (v === "" || DIGITS_ONLY_REGEX.test(v)) {
setInputValue(v); setInputValue(v);
} }
}} }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
/> />
<Button <button
type="button" type="button"
variant="ghost"
size="icon"
disabled={!isValid} 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)} onClick={() => applyDelta(-1)}
title="Apply damage" title="Apply damage"
aria-label="Apply damage" aria-label="Apply damage"
> >
<Sword size={14} /> <Sword size={14} />
</Button> </button>
<Button <button
type="button" type="button"
variant="ghost"
size="icon"
disabled={!isValid} 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)} onClick={() => applyDelta(1)}
title="Apply healing" title="Apply healing"
aria-label="Apply healing" aria-label="Apply healing"
> >
<Heart size={14} /> <Heart size={14} />
</Button> </button>
</div> </div>
</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,93 @@
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
import { type RefObject, useImperativeHandle, useState } from "react";
import { CreatePlayerModal } from "./create-player-modal.js";
import { PlayerManagement } from "./player-management.js";
export interface PlayerCharacterSectionHandle {
openManagement: () => void;
}
interface PlayerCharacterSectionProps {
characters: readonly PlayerCharacter[];
onCreateCharacter: (
name: string,
ac: number,
maxHp: number,
color: string | undefined,
icon: string | undefined,
) => void;
onEditCharacter: (
id: PlayerCharacterId,
fields: {
name?: string;
ac?: number;
maxHp?: number;
color?: string | null;
icon?: string | null;
},
) => void;
onDeleteCharacter: (id: PlayerCharacterId) => void;
}
export const PlayerCharacterSection = function PlayerCharacterSectionInner({
characters,
onCreateCharacter,
onEditCharacter,
onDeleteCharacter,
ref,
}: PlayerCharacterSectionProps & {
ref?: RefObject<PlayerCharacterSectionHandle | null>;
}) {
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) {
onEditCharacter(editingPlayer.id, {
name,
ac,
maxHp,
color: color ?? null,
icon: icon ?? null,
});
} else {
onCreateCharacter(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) => onDeleteCharacter(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

@@ -1,5 +1,5 @@
import { Download, Loader2, Upload } from "lucide-react"; import { Download, Loader2, Upload } from "lucide-react";
import { useRef, useState } from "react"; import { useId, useRef, useState } from "react";
import { getDefaultFetchUrl } from "../adapters/bestiary-index-adapter.js"; import { getDefaultFetchUrl } from "../adapters/bestiary-index-adapter.js";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js"; import { Input } from "./ui/input.js";
@@ -18,11 +18,12 @@ export function SourceFetchPrompt({
fetchAndCacheSource, fetchAndCacheSource,
onSourceLoaded, onSourceLoaded,
onUploadSource, onUploadSource,
}: SourceFetchPromptProps) { }: Readonly<SourceFetchPromptProps>) {
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode)); const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle"); const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const sourceUrlId = useId();
const handleFetch = async () => { const handleFetch = async () => {
setStatus("fetching"); setStatus("fetching");
@@ -64,21 +65,21 @@ export function SourceFetchPrompt({
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div> <div>
<h3 className="text-sm font-semibold text-foreground"> <h3 className="font-semibold text-foreground text-sm">
Load {sourceDisplayName} Load {sourceDisplayName}
</h3> </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 Stat block data for this source needs to be loaded. Enter a URL or
upload a JSON file. upload a JSON file.
</p> </p>
</div> </div>
<div className="flex flex-col gap-2"> <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 Source URL
</label> </label>
<Input <Input
id="source-url" id={sourceUrlId}
type="url" type="url"
value={url} value={url}
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
@@ -88,11 +89,7 @@ export function SourceFetchPrompt({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button onClick={handleFetch} disabled={status === "fetching" || !url}>
size="sm"
onClick={handleFetch}
disabled={status === "fetching" || !url}
>
{status === "fetching" ? ( {status === "fetching" ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" /> <Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : ( ) : (
@@ -101,10 +98,9 @@ export function SourceFetchPrompt({
{status === "fetching" ? "Loading..." : "Load"} {status === "fetching" ? "Loading..." : "Load"}
</Button> </Button>
<span className="text-xs text-muted-foreground">or</span> <span className="text-muted-foreground text-xs">or</span>
<Button <Button
size="sm"
variant="outline" variant="outline"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={status === "fetching"} disabled={status === "fetching"}
@@ -122,7 +118,7 @@ export function SourceFetchPrompt({
</div> </div>
{status === "error" && ( {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} {error}
</div> </div>
)} )}

View File

@@ -1,15 +1,35 @@
import { Database, Trash2 } from "lucide-react"; import { Database, Search, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import {
useCallback,
useEffect,
useMemo,
useOptimistic,
useState,
} from "react";
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js"; import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
import * as bestiaryCache from "../adapters/bestiary-cache.js"; import * as bestiaryCache from "../adapters/bestiary-cache.js";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
interface SourceManagerProps { interface SourceManagerProps {
onCacheCleared: () => void; onCacheCleared: () => void;
} }
export function SourceManager({ onCacheCleared }: SourceManagerProps) { export function SourceManager({
onCacheCleared,
}: Readonly<SourceManagerProps>) {
const [sources, setSources] = useState<CachedSourceInfo[]>([]); 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 loadSources = useCallback(async () => {
const cached = await bestiaryCache.getCachedSources(); const cached = await bestiaryCache.getCachedSources();
@@ -17,26 +37,37 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
}, []); }, []);
useEffect(() => { useEffect(() => {
loadSources(); void loadSources();
}, [loadSources]); }, [loadSources]);
const handleClearSource = async (sourceCode: string) => { const handleClearSource = async (sourceCode: string) => {
applyOptimistic({ type: "remove", sourceCode });
await bestiaryCache.clearSource(sourceCode); await bestiaryCache.clearSource(sourceCode);
await loadSources(); await loadSources();
onCacheCleared(); onCacheCleared();
}; };
const handleClearAll = async () => { const handleClearAll = async () => {
applyOptimistic({ type: "clear" });
await bestiaryCache.clearAll(); await bestiaryCache.clearAll();
await loadSources(); await loadSources();
onCacheCleared(); onCacheCleared();
}; };
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 ( return (
<div className="flex flex-col items-center gap-2 py-8 text-center"> <div className="flex flex-col items-center gap-2 py-8 text-center">
<Database className="h-8 w-8 text-muted-foreground" /> <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> </div>
); );
} }
@@ -44,32 +75,46 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <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 Cached Sources
</span> </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" /> <Trash2 className="mr-1 h-3 w-3" />
Clear All Clear All
</Button> </Button>
</div> </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"> <ul className="flex flex-col gap-1">
{sources.map((source) => ( {filteredSources.map((source) => (
<li <li
key={source.sourceCode} key={source.sourceCode}
className="flex items-center justify-between rounded-md border border-border px-3 py-2" className="flex items-center justify-between rounded-md border border-border px-3 py-2"
> >
<div> <div>
<span className="text-sm text-foreground"> <span className="text-foreground text-sm">
{source.displayName} {source.displayName}
</span> </span>
<span className="ml-2 text-xs text-muted-foreground"> <span className="ml-2 text-muted-foreground text-xs">
{source.creatureCount} creatures {source.creatureCount} creatures
</span> </span>
</div> </div>
<button <button
type="button" type="button"
onClick={() => handleClearSource(source.sourceCode)} 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" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>

View File

@@ -5,9 +5,12 @@ import { useEffect, useState } from "react";
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js"; import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
import type { BulkImportState } from "../hooks/use-bulk-import.js"; import type { BulkImportState } from "../hooks/use-bulk-import.js";
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js"; import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
import { cn } from "../lib/utils.js";
import { BulkImportPrompt } from "./bulk-import-prompt.js"; import { BulkImportPrompt } from "./bulk-import-prompt.js";
import { SourceFetchPrompt } from "./source-fetch-prompt.js"; import { SourceFetchPrompt } from "./source-fetch-prompt.js";
import { SourceManager } from "./source-manager.js";
import { StatBlock } from "./stat-block.js"; import { StatBlock } from "./stat-block.js";
import { Button } from "./ui/button.js";
interface StatBlockPanelProps { interface StatBlockPanelProps {
creatureId: CreatureId | null; creatureId: CreatureId | null;
@@ -20,8 +23,8 @@ interface StatBlockPanelProps {
) => Promise<void>; ) => Promise<void>;
refreshCache: () => Promise<void>; refreshCache: () => Promise<void>;
panelRole: "browse" | "pinned"; panelRole: "browse" | "pinned";
isFolded: boolean; isCollapsed: boolean;
onToggleFold: () => void; onToggleCollapse: () => void;
onPin: () => void; onPin: () => void;
onUnpin: () => void; onUnpin: () => void;
showPinButton: boolean; showPinButton: boolean;
@@ -31,6 +34,7 @@ interface StatBlockPanelProps {
bulkImportState?: BulkImportState; bulkImportState?: BulkImportState;
onStartBulkImport?: (baseUrl: string) => void; onStartBulkImport?: (baseUrl: string) => void;
onBulkImportDone?: () => void; onBulkImportDone?: () => void;
sourceManagerMode?: boolean;
} }
function extractSourceCode(cId: CreatureId): string { function extractSourceCode(cId: CreatureId): string {
@@ -39,25 +43,26 @@ function extractSourceCode(cId: CreatureId): string {
return cId.slice(0, colonIndex).toUpperCase(); return cId.slice(0, colonIndex).toUpperCase();
} }
function FoldedTab({ function CollapsedTab({
creatureName, creatureName,
side, side,
onToggleFold, onToggleCollapse,
}: { }: Readonly<{
creatureName: string; creatureName: string;
side: "left" | "right"; side: "left" | "right";
onToggleFold: () => void; onToggleCollapse: () => void;
}) { }>) {
return ( return (
<button <button
type="button" type="button"
onClick={onToggleFold} onClick={onToggleCollapse}
className={`flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral ${ className={cn(
side === "right" ? "self-start" : "self-end" "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" )}
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} {creatureName}
</span> </span>
</button> </button>
@@ -67,50 +72,53 @@ function FoldedTab({
function PanelHeader({ function PanelHeader({
panelRole, panelRole,
showPinButton, showPinButton,
onToggleFold, onToggleCollapse,
onPin, onPin,
onUnpin, onUnpin,
}: { }: Readonly<{
panelRole: "browse" | "pinned"; panelRole: "browse" | "pinned";
showPinButton: boolean; showPinButton: boolean;
onToggleFold: () => void; onToggleCollapse: () => void;
onPin: () => void; onPin: () => void;
onUnpin: () => void; onUnpin: () => void;
}) { }>) {
return ( 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"> <div className="flex items-center gap-1">
{panelRole === "browse" && ( {panelRole === "browse" && (
<button <Button
type="button" variant="ghost"
onClick={onToggleFold} size="icon-sm"
className="text-muted-foreground hover:text-hover-neutral" onClick={onToggleCollapse}
aria-label="Fold stat block panel" className="text-muted-foreground"
aria-label="Collapse stat block panel"
> >
<PanelRightClose className="h-4 w-4" /> <PanelRightClose className="h-4 w-4" />
</button> </Button>
)} )}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{panelRole === "browse" && showPinButton && ( {panelRole === "browse" && showPinButton && (
<button <Button
type="button" variant="ghost"
size="icon-sm"
onClick={onPin} onClick={onPin}
className="text-muted-foreground hover:text-hover-neutral" className="text-muted-foreground"
aria-label="Pin creature" aria-label="Pin creature"
> >
<Pin className="h-4 w-4" /> <Pin className="h-4 w-4" />
</button> </Button>
)} )}
{panelRole === "pinned" && ( {panelRole === "pinned" && (
<button <Button
type="button" variant="ghost"
size="icon-sm"
onClick={onUnpin} onClick={onUnpin}
className="text-muted-foreground hover:text-hover-neutral" className="text-muted-foreground"
aria-label="Unpin creature" aria-label="Unpin creature"
> >
<PinOff className="h-4 w-4" /> <PinOff className="h-4 w-4" />
</button> </Button>
)} )}
</div> </div>
</div> </div>
@@ -118,48 +126,52 @@ function PanelHeader({
} }
function DesktopPanel({ function DesktopPanel({
isFolded, isCollapsed,
side, side,
creatureName, creatureName,
panelRole, panelRole,
showPinButton, showPinButton,
onToggleFold, onToggleCollapse,
onPin, onPin,
onUnpin, onUnpin,
children, children,
}: { }: Readonly<{
isFolded: boolean; isCollapsed: boolean;
side: "left" | "right"; side: "left" | "right";
creatureName: string; creatureName: string;
panelRole: "browse" | "pinned"; panelRole: "browse" | "pinned";
showPinButton: boolean; showPinButton: boolean;
onToggleFold: () => void; onToggleCollapse: () => void;
onPin: () => void; onPin: () => void;
onUnpin: () => void; onUnpin: () => void;
children: ReactNode; children: ReactNode;
}) { }>) {
const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l"; const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l";
const foldedTranslate = const collapsedTranslate =
side === "right" side === "right"
? "translate-x-[calc(100%-40px)]" ? "translate-x-[calc(100%-40px)]"
: "translate-x-[calc(-100%+40px)]"; : "translate-x-[calc(-100%+40px)]";
return ( return (
<div <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 ? ( {isCollapsed ? (
<FoldedTab <CollapsedTab
creatureName={creatureName} creatureName={creatureName}
side={side} side={side}
onToggleFold={onToggleFold} onToggleCollapse={onToggleCollapse}
/> />
) : ( ) : (
<> <>
<PanelHeader <PanelHeader
panelRole={panelRole} panelRole={panelRole}
showPinButton={showPinButton} showPinButton={showPinButton}
onToggleFold={onToggleFold} onToggleCollapse={onToggleCollapse}
onPin={onPin} onPin={onPin}
onUnpin={onUnpin} onUnpin={onUnpin}
/> />
@@ -173,36 +185,40 @@ function DesktopPanel({
function MobileDrawer({ function MobileDrawer({
onDismiss, onDismiss,
children, children,
}: { }: Readonly<{
onDismiss: () => void; onDismiss: () => void;
children: ReactNode; children: ReactNode;
}) { }>) {
const { offsetX, isSwiping, handlers } = useSwipeToDismiss(onDismiss); const { offsetX, isSwiping, handlers } = useSwipeToDismiss(onDismiss);
return ( return (
<div className="fixed inset-0 z-50"> <div className="fixed inset-0 z-50">
<button <button
type="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} onClick={onDismiss}
aria-label="Close stat block" aria-label="Close stat block"
/> />
<div <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={ style={
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
} }
{...handlers} {...handlers}
> >
<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">
<button <Button
type="button" variant="ghost"
size="icon-sm"
onClick={onDismiss} onClick={onDismiss}
className="text-muted-foreground hover:text-hover-neutral" className="text-muted-foreground"
aria-label="Fold stat block panel" aria-label="Collapse stat block panel"
> >
<PanelRightClose className="h-4 w-4" /> <PanelRightClose className="h-4 w-4" />
</button> </Button>
</div> </div>
<div className="h-[calc(100%-41px)] overflow-y-auto p-4"> <div className="h-[calc(100%-41px)] overflow-y-auto p-4">
{children} {children}
@@ -220,8 +236,8 @@ export function StatBlockPanel({
uploadAndCacheSource, uploadAndCacheSource,
refreshCache, refreshCache,
panelRole, panelRole,
isFolded, isCollapsed,
onToggleFold, onToggleCollapse,
onPin, onPin,
onUnpin, onUnpin,
showPinButton, showPinButton,
@@ -231,15 +247,16 @@ export function StatBlockPanel({
bulkImportState, bulkImportState,
onStartBulkImport, onStartBulkImport,
onBulkImportDone, onBulkImportDone,
}: StatBlockPanelProps) { sourceManagerMode,
}: Readonly<StatBlockPanelProps>) {
const [isDesktop, setIsDesktop] = useState( const [isDesktop, setIsDesktop] = useState(
() => window.matchMedia("(min-width: 1024px)").matches, () => globalThis.matchMedia("(min-width: 1024px)").matches,
); );
const [needsFetch, setNeedsFetch] = useState(false); const [needsFetch, setNeedsFetch] = useState(false);
const [checkingCache, setCheckingCache] = useState(false); const [checkingCache, setCheckingCache] = useState(false);
useEffect(() => { useEffect(() => {
const mq = window.matchMedia("(min-width: 1024px)"); const mq = globalThis.matchMedia("(min-width: 1024px)");
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches); const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mq.addEventListener("change", handler); mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler); return () => mq.removeEventListener("change", handler);
@@ -258,13 +275,13 @@ export function StatBlockPanel({
} }
setCheckingCache(true); setCheckingCache(true);
isSourceCached(sourceCode).then((cached) => { void isSourceCached(sourceCode).then((cached) => {
setNeedsFetch(!cached); setNeedsFetch(!cached);
setCheckingCache(false); setCheckingCache(false);
}); });
}, [creatureId, creature, isSourceCached]); }, [creatureId, creature, isSourceCached]);
if (!creatureId && !bulkImportMode) return null; if (!creatureId && !bulkImportMode && !sourceManagerMode) return null;
const sourceCode = creatureId ? extractSourceCode(creatureId) : ""; const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
@@ -274,6 +291,10 @@ export function StatBlockPanel({
}; };
const renderContent = () => { const renderContent = () => {
if (sourceManagerMode) {
return <SourceManager onCacheCleared={refreshCache} />;
}
if ( if (
bulkImportMode && bulkImportMode &&
bulkImportState && bulkImportState &&
@@ -291,7 +312,7 @@ export function StatBlockPanel({
if (checkingCache) { if (checkingCache) {
return ( return (
<div className="p-4 text-sm text-muted-foreground">Loading...</div> <div className="p-4 text-muted-foreground text-sm">Loading...</div>
); );
} }
@@ -312,24 +333,26 @@ export function StatBlockPanel({
} }
return ( return (
<div className="p-4 text-sm text-muted-foreground"> <div className="p-4 text-muted-foreground text-sm">
No stat block available No stat block available
</div> </div>
); );
}; };
const creatureName = let fallbackName = "Creature";
creature?.name ?? (bulkImportMode ? "Bulk Import" : "Creature"); if (sourceManagerMode) fallbackName = "Sources";
else if (bulkImportMode) fallbackName = "Import All Sources";
const creatureName = creature?.name ?? fallbackName;
if (isDesktop) { if (isDesktop) {
return ( return (
<DesktopPanel <DesktopPanel
isFolded={isFolded} isCollapsed={isCollapsed}
side={side} side={side}
creatureName={creatureName} creatureName={creatureName}
panelRole={panelRole} panelRole={panelRole}
showPinButton={showPinButton} showPinButton={showPinButton}
onToggleFold={onToggleFold} onToggleCollapse={onToggleCollapse}
onPin={onPin} onPin={onPin}
onUnpin={onUnpin} onUnpin={onUnpin}
> >
@@ -338,7 +361,7 @@ export function StatBlockPanel({
); );
} }
if (panelRole === "pinned") return null; if (panelRole === "pinned" || isCollapsed) return null;
return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>; return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,21 @@
import { forwardRef, type InputHTMLAttributes } from "react"; import type { InputHTMLAttributes, RefObject } from "react";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
type InputProps = InputHTMLAttributes<HTMLInputElement>; type InputProps = InputHTMLAttributes<HTMLInputElement>;
export const Input = forwardRef<HTMLInputElement, InputProps>( export const Input = ({
({ className, ...props }, ref) => { className,
ref,
...props
}: InputProps & { ref?: RefObject<HTMLInputElement | null> }) => {
return ( return (
<input <input
ref={ref} ref={ref}
className={cn( 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", "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-foreground text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
{...props} {...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,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

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

View File

@@ -48,7 +48,7 @@ export function useBulkImport(): BulkImportHook {
countersRef.current = { completed: 0, failed: 0 }; countersRef.current = { completed: 0, failed: 0 };
setState({ status: "loading", total, completed: 0, failed: 0 }); setState({ status: "loading", total, completed: 0, failed: 0 });
(async () => { void (async () => {
const cacheChecks = await Promise.all( const cacheChecks = await Promise.all(
allCodes.map(async (code) => ({ allCodes.map(async (code) => ({
code, code,
@@ -73,9 +73,15 @@ export function useBulkImport(): BulkImportHook {
setState((s) => ({ ...s, completed: alreadyCached })); setState((s) => ({ ...s, completed: alreadyCached }));
const batches: { code: string }[][] = [];
for (let i = 0; i < uncached.length; i += BATCH_SIZE) { for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
const batch = uncached.slice(i, i + BATCH_SIZE); batches.push(uncached.slice(i, i + BATCH_SIZE));
await Promise.allSettled( }
await batches.reduce(
(chain, batch) =>
chain.then(() =>
Promise.allSettled(
batch.map(async ({ code }) => { batch.map(async ({ code }) => {
const url = getDefaultFetchUrl(code, baseUrl); const url = getDefaultFetchUrl(code, baseUrl);
try { try {
@@ -95,8 +101,10 @@ export function useBulkImport(): BulkImportHook {
failed: countersRef.current.failed, failed: countersRef.current.failed,
}); });
}), }),
),
),
Promise.resolve() as Promise<unknown>,
); );
}
await refreshCache(); await refreshCache();

View File

@@ -17,8 +17,10 @@ import type {
BestiaryIndexEntry, BestiaryIndexEntry,
CombatantId, CombatantId,
ConditionId, ConditionId,
CreatureId,
DomainEvent, DomainEvent,
Encounter, Encounter,
PlayerCharacter,
} from "@initiative/domain"; } from "@initiative/domain";
import { import {
combatantId, combatantId,
@@ -32,6 +34,8 @@ import {
saveEncounter, saveEncounter,
} from "../persistence/encounter-storage.js"; } from "../persistence/encounter-storage.js";
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
const EMPTY_ENCOUNTER: Encounter = { const EMPTY_ENCOUNTER: Encounter = {
combatants: [], combatants: [],
activeIndex: 0, activeIndex: 0,
@@ -47,7 +51,7 @@ function initializeEncounter(): Encounter {
function deriveNextId(encounter: Encounter): number { function deriveNextId(encounter: Encounter): number {
let max = 0; let max = 0;
for (const c of encounter.combatants) { for (const c of encounter.combatants) {
const match = /^c-(\d+)$/.exec(c.id); const match = COMBATANT_ID_REGEX.exec(c.id);
if (match) { if (match) {
const n = Number.parseInt(match[1], 10); const n = Number.parseInt(match[1], 10);
if (n > max) max = n; if (n > max) max = n;
@@ -262,7 +266,7 @@ export function useEncounter() {
}, [makeStore]); }, [makeStore]);
const addFromBestiary = useCallback( const addFromBestiary = useCallback(
(entry: BestiaryIndexEntry) => { (entry: BestiaryIndexEntry): CreatureId | null => {
const store = makeStore(); const store = makeStore();
const existingNames = store.get().combatants.map((c) => c.name); const existingNames = store.get().combatants.map((c) => c.name);
const { newName, renames } = resolveCreatureName( const { newName, renames } = resolveCreatureName(
@@ -281,7 +285,7 @@ export function useEncounter() {
// Add combatant with resolved name // Add combatant with resolved name
const id = combatantId(`c-${++nextId.current}`); const id = combatantId(`c-${++nextId.current}`);
const addResult = addCombatantUseCase(makeStore(), id, newName); const addResult = addCombatantUseCase(makeStore(), id, newName);
if (isDomainError(addResult)) return; if (isDomainError(addResult)) return null;
// Set HP // Set HP
const hpResult = setHpUseCase(makeStore(), id, entry.hp); const hpResult = setHpUseCase(makeStore(), id, entry.hp);
@@ -300,8 +304,8 @@ export function useEncounter() {
// Derive creatureId from source + name // Derive creatureId from source + name
const slug = entry.name const slug = entry.name
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9]+/g, "-") .replaceAll(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, ""); .replaceAll(/(^-|-$)/g, "");
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`); const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
// Set creatureId on the combatant (use store.save to keep ref in sync for batch calls) // Set creatureId on the combatant (use store.save to keep ref in sync for batch calls)
@@ -314,13 +318,78 @@ export function useEncounter() {
}); });
setEvents((prev) => [...prev, ...addResult]); setEvents((prev) => [...prev, ...addResult]);
return cId;
}, },
[makeStore, editCombatant], [makeStore],
);
const addFromPlayerCharacter = useCallback(
(pc: PlayerCharacter) => {
const store = makeStore();
const existingNames = store.get().combatants.map((c) => c.name);
const { newName, renames } = resolveCreatureName(pc.name, existingNames);
for (const { from, to } of renames) {
const target = store.get().combatants.find((c) => c.name === from);
if (target) {
editCombatantUseCase(makeStore(), target.id, to);
}
}
const id = combatantId(`c-${++nextId.current}`);
const addResult = addCombatantUseCase(makeStore(), id, newName);
if (isDomainError(addResult)) return;
// Set HP
const hpResult = setHpUseCase(makeStore(), id, pc.maxHp);
if (!isDomainError(hpResult)) {
setEvents((prev) => [...prev, ...hpResult]);
}
// Set AC
if (pc.ac > 0) {
const acResult = setAcUseCase(makeStore(), id, pc.ac);
if (!isDomainError(acResult)) {
setEvents((prev) => [...prev, ...acResult]);
}
}
// Set color, icon, and playerCharacterId on the combatant
const currentEncounter = store.get();
store.save({
...currentEncounter,
combatants: currentEncounter.combatants.map((c) =>
c.id === id
? {
...c,
color: pc.color,
icon: pc.icon,
playerCharacterId: pc.id,
}
: c,
),
});
setEvents((prev) => [...prev, ...addResult]);
},
[makeStore],
);
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 { return {
encounter, encounter,
events, events,
isEmpty,
hasCreatureCombatants,
canRollAllInitiative,
advanceTurn, advanceTurn,
retreatTurn, retreatTurn,
addCombatant, addCombatant,
@@ -334,6 +403,7 @@ export function useEncounter() {
toggleCondition, toggleCondition,
toggleConcentration, toggleConcentration,
addFromBestiary, addFromBestiary,
addFromPlayerCharacter,
makeStore, makeStore,
} as const; } 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,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,98 @@
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;
}
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;
}
const CYCLE: ThemePreference[] = ["system", "light", "dark"];
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();
}, []);
const cycleTheme = useCallback(() => {
const idx = CYCLE.indexOf(currentPreference);
const next = CYCLE[(idx + 1) % CYCLE.length];
setPreference(next);
}, [setPreference]);
return { preference, resolved, setPreference, cycleTheme } as const;
}

View File

@@ -1,14 +1,14 @@
@import "tailwindcss"; @import "tailwindcss";
@theme { @theme {
--color-background: #0f172a; --color-background: #0e1a2e;
--color-foreground: #e2e8f0; --color-foreground: #e2e8f0;
--color-muted: #64748b; --color-muted: #7a8ba4;
--color-muted-foreground: #94a3b8; --color-muted-foreground: #94a3b8;
--color-card: #1e293b; --color-card: #1a2e4a;
--color-card-foreground: #e2e8f0; --color-card-foreground: #e2e8f0;
--color-border: #334155; --color-border: #2a5088;
--color-input: #334155; --color-input: #2a5088;
--color-primary: #3b82f6; --color-primary: #3b82f6;
--color-primary-foreground: #ffffff; --color-primary-foreground: #ffffff;
--color-accent: #3b82f6; --color-accent: #3b82f6;
@@ -16,15 +16,50 @@
--color-hover-neutral: var(--color-primary); --color-hover-neutral: var(--color-primary);
--color-hover-action: var(--color-primary); --color-hover-action: var(--color-primary);
--color-hover-destructive: var(--color-destructive); --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-action-bg: var(--color-muted);
--color-hover-destructive-bg: transparent; --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-sm: 0.25rem;
--radius-md: 0.375rem; --radius-md: 0.5rem;
--radius-lg: 0.5rem; --radius-lg: 0.75rem;
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif; --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 { @keyframes concentration-shake {
0% { 0% {
translate: 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)); @custom-variant pointer-coarse (@media (pointer: coarse));
@utility animate-confirm-pulse { @utility animate-confirm-pulse {
@@ -100,6 +204,38 @@
concentration-glow 1200ms ease-out; 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-color: var(--color-border) transparent;
scrollbar-width: thin; scrollbar-width: thin;
@@ -107,6 +243,16 @@
body { body {
background-color: var(--color-background); 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); color: var(--color-foreground);
font-family: var(--font-sans); font-family: var(--font-sans);
} }
[data-theme="light"] body {
background-image: none;
}

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, creatureId,
type Encounter, type Encounter,
isDomainError, isDomainError,
playerCharacterId,
VALID_CONDITION_IDS, VALID_CONDITION_IDS,
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
} from "@initiative/domain"; } from "@initiative/domain";
const STORAGE_KEY = "initiative:encounter"; const STORAGE_KEY = "initiative:encounter";
@@ -70,12 +73,29 @@ function rehydrateCombatant(c: unknown) {
typeof entry.initiative === "number" ? entry.initiative : undefined, 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 = { const shared = {
...base, ...base,
ac: validateAc(entry.ac), ac: validateAc(entry.ac),
conditions: validateConditions(entry.conditions), conditions: validateConditions(entry.conditions),
isConcentrating: entry.isConcentrating === true ? true : undefined, isConcentrating: entry.isConcentrating === true ? true : undefined,
creatureId: validateCreatureId(entry.creatureId), creatureId: validateCreatureId(entry.creatureId),
color,
icon,
playerCharacterId: pcId,
}; };
const hp = validateHp(entry.maxHp, entry.currentHp); const hp = validateHp(entry.maxHp, entry.currentHp);

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

@@ -1,14 +1,14 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json", "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
"files": { "files": {
"includes": [ "includes": [
"**", "**",
"!**/dist/**", "!**/dist",
"!.claude/**", "!.claude",
"!.specify/**", "!.specify",
"!specs/**", "!specs",
"!coverage/**", "!coverage",
"!.pnpm-store/**" "!.pnpm-store"
] ]
}, },
"assist": { "assist": {
@@ -21,6 +21,12 @@
} }
} }
}, },
"css": {
"parser": {
"cssModules": false,
"tailwindDirectives": true
}
},
"formatter": { "formatter": {
"enabled": true, "enabled": true,
"indentStyle": "tab", "indentStyle": "tab",
@@ -30,13 +36,93 @@
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true, "recommended": true,
"a11y": {
"noNoninteractiveElementInteractions": "error"
},
"complexity": { "complexity": {
"noExcessiveCognitiveComplexity": { "noExcessiveCognitiveComplexity": {
"level": "error", "level": "error",
"options": { "options": {
"maxAllowedComplexity": 15 "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"
} }
} }
} }

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

@@ -1,12 +1,19 @@
{ {
"private": true, "private": true,
"packageManager": "pnpm@10.6.0", "packageManager": "pnpm@10.6.0",
"pnpm": {
"overrides": {
"undici": ">=7.24.0"
}
},
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.0.0", "@biomejs/biome": "2.4.7",
"@vitest/coverage-v8": "^3.2.4", "@vitest/coverage-v8": "^3.2.4",
"jscpd": "^4.0.8", "jscpd": "^4.0.8",
"knip": "^5.85.0", "knip": "^5.85.0",
"lefthook": "^1.11.0", "lefthook": "^1.11.0",
"oxlint": "^1.55.0",
"oxlint-tsgolint": "^0.16.0",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"vitest": "^3.0.0" "vitest": "^3.0.0"
}, },
@@ -21,6 +28,9 @@
"test:watch": "vitest", "test:watch": "vitest",
"knip": "knip", "knip": "knip",
"jscpd": "jscpd", "jscpd": "jscpd",
"check": "pnpm audit --audit-level=high && knip && biome check . && tsc --build && vitest run && jscpd" "oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
"check:ignores": "node scripts/check-lint-ignores.mjs",
"check:classnames": "node scripts/check-cn-classnames.mjs",
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && tsc --build && vitest run && jscpd"
} }
} }

View File

@@ -0,0 +1,54 @@
import type { Encounter, PlayerCharacter } from "@initiative/domain";
import { isDomainError } from "@initiative/domain";
import type { EncounterStore, PlayerCharacterStore } from "../ports.js";
export function requireSaved<T>(value: T | null): T {
if (value === null) throw new Error("Expected store.saved to be non-null");
return value;
}
export function expectSuccess<T>(
result: T,
): asserts result is Exclude<T, { kind: "domain-error" }> {
if (isDomainError(result)) {
throw new Error(`Expected success, got domain error: ${result.message}`);
}
}
export function expectError(result: unknown): asserts result is {
kind: "domain-error";
code: string;
message: string;
} {
if (!isDomainError(result)) {
throw new Error("Expected domain error");
}
}
export function stubEncounterStore(
initial: Encounter,
): EncounterStore & { saved: Encounter | null } {
const stub = {
saved: null as Encounter | null,
get: () => initial,
save: (e: Encounter) => {
stub.saved = e;
stub.get = () => e;
},
};
return stub;
}
export function stubPlayerCharacterStore(
initial: readonly PlayerCharacter[],
): PlayerCharacterStore & { saved: readonly PlayerCharacter[] | null } {
const stub = {
saved: null as readonly PlayerCharacter[] | null,
getAll: () => [...initial],
save: (characters: PlayerCharacter[]) => {
stub.saved = characters;
stub.getAll = () => [...characters];
},
};
return stub;
}

View File

@@ -0,0 +1,237 @@
import {
type Creature,
combatantId,
createEncounter,
creatureId,
isDomainError,
} from "@initiative/domain";
import { describe, expect, it } from "vitest";
import { rollAllInitiativeUseCase } from "../roll-all-initiative-use-case.js";
import {
expectError,
expectSuccess,
requireSaved,
stubEncounterStore,
} from "./helpers.js";
const CREATURE_A = creatureId("creature-a");
const CREATURE_B = creatureId("creature-b");
function makeCreature(id: string, dex = 14): Creature {
return {
id: creatureId(id),
name: `Creature ${id}`,
source: "mm",
sourceDisplayName: "Monster Manual",
size: "Medium",
type: "humanoid",
alignment: "neutral",
ac: 12,
hp: { average: 10, formula: "2d8+2" },
speed: "30 ft.",
abilities: { str: 10, dex, con: 10, int: 10, wis: 10, cha: 10 },
cr: "1",
initiativeProficiency: 0,
proficiencyBonus: 2,
passive: 10,
};
}
function encounterWithCombatants(
combatants: Array<{
name: string;
creatureId?: string;
initiative?: number;
}>,
) {
const result = createEncounter(
combatants.map((c) => ({
id: combatantId(c.name),
name: c.name,
creatureId: c.creatureId ? creatureId(c.creatureId) : undefined,
initiative: c.initiative,
})),
);
if (isDomainError(result)) throw new Error("Setup failed");
return result;
}
describe("rollAllInitiativeUseCase", () => {
it("skips combatants without creatureId", () => {
const enc = encounterWithCombatants([
{ name: "Fighter" },
{ name: "Goblin", creatureId: "creature-a" },
]);
const store = stubEncounterStore(enc);
const creature = makeCreature("creature-a");
const result = rollAllInitiativeUseCase(
store,
() => 10,
(id) => (id === CREATURE_A ? creature : undefined),
);
expectSuccess(result);
expect(result.events.length).toBeGreaterThan(0);
const saved = requireSaved(store.saved);
const fighter = saved.combatants.find((c) => c.name === "Fighter");
const goblin = saved.combatants.find((c) => c.name === "Goblin");
expect(fighter?.initiative).toBeUndefined();
expect(goblin?.initiative).toBeDefined();
});
it("skips combatants that already have initiative", () => {
const enc = encounterWithCombatants([
{ name: "Goblin", creatureId: "creature-a", initiative: 15 },
]);
const store = stubEncounterStore(enc);
const result = rollAllInitiativeUseCase(
store,
() => 10,
() => makeCreature("creature-a"),
);
expectSuccess(result);
expect(result.events).toHaveLength(0);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(15);
});
it("counts skippedNoSource when creature lookup returns undefined", () => {
const enc = encounterWithCombatants([
{ name: "Unknown", creatureId: "missing" },
]);
const store = stubEncounterStore(enc);
const result = rollAllInitiativeUseCase(
store,
() => 10,
() => undefined,
);
expectSuccess(result);
expect(result.skippedNoSource).toBe(1);
expect(result.events).toHaveLength(0);
});
it("accumulates events from multiple setInitiative calls", () => {
const enc = encounterWithCombatants([
{ name: "A", creatureId: "creature-a" },
{ name: "B", creatureId: "creature-b" },
]);
const store = stubEncounterStore(enc);
const creatureA = makeCreature("creature-a");
const creatureB = makeCreature("creature-b");
const result = rollAllInitiativeUseCase(
store,
() => 10,
(id) => {
if (id === CREATURE_A) return creatureA;
if (id === CREATURE_B) return creatureB;
return undefined;
},
);
expectSuccess(result);
expect(result.events).toHaveLength(2);
});
it("returns early with domain error on invalid dice roll", () => {
const enc = encounterWithCombatants([
{ name: "A", creatureId: "creature-a" },
{ name: "B", creatureId: "creature-b" },
]);
const store = stubEncounterStore(enc);
// rollDice returns 0 (invalid — must be 120), triggers early return
const result = rollAllInitiativeUseCase(
store,
() => 0,
(id) => {
if (id === CREATURE_A) return makeCreature("creature-a");
if (id === CREATURE_B) return makeCreature("creature-b");
return undefined;
},
);
expectError(result);
expect(result.code).toBe("invalid-dice-roll");
// Store should NOT have been saved since the loop aborted
expect(store.saved).toBeNull();
});
it("uses higher roll with advantage", () => {
const enc = encounterWithCombatants([
{ name: "A", creatureId: "creature-a" },
]);
const store = stubEncounterStore(enc);
const creature = makeCreature("creature-a");
// Alternating rolls: 5, 15 → advantage picks 15
// Dex 14 → modifier +2, so 15 + 2 = 17
let call = 0;
const result = rollAllInitiativeUseCase(
store,
() => (++call % 2 === 1 ? 5 : 15),
(id) => (id === CREATURE_A ? creature : undefined),
"advantage",
);
expectSuccess(result);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(17);
});
it("uses lower roll with disadvantage", () => {
const enc = encounterWithCombatants([
{ name: "A", creatureId: "creature-a" },
]);
const store = stubEncounterStore(enc);
const creature = makeCreature("creature-a");
// Alternating rolls: 15, 5 → disadvantage picks 5
// Dex 14 → modifier +2, so 5 + 2 = 7
let call = 0;
const result = rollAllInitiativeUseCase(
store,
() => (++call % 2 === 1 ? 15 : 5),
(id) => (id === CREATURE_A ? creature : undefined),
"disadvantage",
);
expectSuccess(result);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(7);
});
it("saves encounter once at the end", () => {
const enc = encounterWithCombatants([
{ name: "A", creatureId: "creature-a" },
{ name: "B", creatureId: "creature-b" },
]);
const store = stubEncounterStore(enc);
const creatureA = makeCreature("creature-a");
const creatureB = makeCreature("creature-b");
let saveCount = 0;
const originalSave = store.save.bind(store);
store.save = (e) => {
saveCount++;
originalSave(e);
};
rollAllInitiativeUseCase(
store,
() => 10,
(id) => {
if (id === CREATURE_A) return creatureA;
if (id === CREATURE_B) return creatureB;
return undefined;
},
);
expect(saveCount).toBe(1);
const saved = requireSaved(store.saved);
expect(saved.combatants[0].initiative).toBeDefined();
expect(saved.combatants[1].initiative).toBeDefined();
});
});

View File

@@ -0,0 +1,191 @@
import {
type Creature,
type CreatureId,
combatantId,
createEncounter,
creatureId,
isDomainError,
} from "@initiative/domain";
import { describe, expect, it } from "vitest";
import { addCombatantUseCase } from "../add-combatant-use-case.js";
import { rollInitiativeUseCase } from "../roll-initiative-use-case.js";
import { expectError, requireSaved, stubEncounterStore } from "./helpers.js";
const GOBLIN_ID = creatureId("goblin");
function makeCreature(overrides?: Partial<Creature>): Creature {
return {
id: GOBLIN_ID,
name: "Goblin",
source: "mm",
sourceDisplayName: "Monster Manual",
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,
...overrides,
};
}
function encounterWithCreatureLink(name: string, creature: CreatureId) {
const enc = createEncounter([]);
if (isDomainError(enc)) throw new Error("Setup failed");
const id = combatantId(name);
const store = stubEncounterStore(enc);
addCombatantUseCase(store, id, name);
const saved = requireSaved(store.saved);
const result = createEncounter(
saved.combatants.map((c) =>
c.id === id ? { ...c, creatureId: creature } : c,
),
saved.activeIndex,
saved.roundNumber,
);
if (isDomainError(result)) throw new Error("Setup failed");
return result;
}
describe("rollInitiativeUseCase", () => {
it("returns domain error when combatant not found", () => {
const enc = createEncounter([]);
if (isDomainError(enc)) throw new Error("Setup failed");
const store = stubEncounterStore(enc);
const result = rollInitiativeUseCase(
store,
combatantId("unknown"),
[10],
() => undefined,
);
expectError(result);
expect(result.code).toBe("combatant-not-found");
expect(store.saved).toBeNull();
});
it("returns domain error when combatant has no creature link", () => {
const enc = createEncounter([]);
if (isDomainError(enc)) throw new Error("Setup failed");
const store1 = stubEncounterStore(enc);
addCombatantUseCase(store1, combatantId("Fighter"), "Fighter");
const store = stubEncounterStore(requireSaved(store1.saved));
const result = rollInitiativeUseCase(
store,
combatantId("Fighter"),
[10],
() => undefined,
);
expectError(result);
expect(result.code).toBe("no-creature-link");
expect(store.saved).toBeNull();
});
it("returns domain error when creature not found in getter", () => {
const enc = encounterWithCreatureLink("Goblin", GOBLIN_ID);
const store = stubEncounterStore(enc);
const result = rollInitiativeUseCase(
store,
combatantId("Goblin"),
[10],
() => undefined,
);
expectError(result);
expect(result.code).toBe("creature-not-found");
expect(store.saved).toBeNull();
});
it("calculates initiative from creature and saves", () => {
const creature = makeCreature();
const enc = encounterWithCreatureLink("Goblin", GOBLIN_ID);
const store = stubEncounterStore(enc);
// Dex 14 -> modifier +2, CR 1/4 -> PB 2, initiativeProficiency 0
// So initiative modifier = 2 + 0*2 = 2
// Roll 10 + modifier 2 = 12
const result = rollInitiativeUseCase(
store,
combatantId("Goblin"),
[10],
(id) => (id === GOBLIN_ID ? creature : undefined),
);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(12);
});
it("uses higher roll with advantage", () => {
const creature = makeCreature();
const enc = encounterWithCreatureLink("Goblin", GOBLIN_ID);
const store = stubEncounterStore(enc);
// Dex 14 -> modifier +2, advantage picks max(5, 15) = 15, 15 + 2 = 17
const result = rollInitiativeUseCase(
store,
combatantId("Goblin"),
[5, 15],
(id) => (id === GOBLIN_ID ? creature : undefined),
"advantage",
);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(17);
});
it("uses lower roll with disadvantage", () => {
const creature = makeCreature();
const enc = encounterWithCreatureLink("Goblin", GOBLIN_ID);
const store = stubEncounterStore(enc);
// Dex 14 -> modifier +2, disadvantage picks min(5, 15) = 5, 5 + 2 = 7
const result = rollInitiativeUseCase(
store,
combatantId("Goblin"),
[5, 15],
(id) => (id === GOBLIN_ID ? creature : undefined),
"disadvantage",
);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(7);
});
it("applies initiative proficiency bonus correctly", () => {
// CR 5 -> PB 3, dex 16 -> mod +3, initiativeProficiency 1
// modifier = 3 + 1*3 = 6, roll 8 + 6 = 14
const creature = makeCreature({
abilities: {
str: 10,
dex: 16,
con: 10,
int: 10,
wis: 10,
cha: 10,
},
cr: "5",
initiativeProficiency: 1,
});
const enc = encounterWithCreatureLink("Monster", GOBLIN_ID);
const store = stubEncounterStore(enc);
const result = rollInitiativeUseCase(
store,
combatantId("Monster"),
[8],
(id) => (id === GOBLIN_ID ? creature : undefined),
);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(14);
});
});

View File

@@ -0,0 +1,388 @@
import {
type ConditionId,
combatantId,
createEncounter,
isDomainError,
playerCharacterId,
} from "@initiative/domain";
import { describe, expect, it } from "vitest";
import { addCombatantUseCase } from "../add-combatant-use-case.js";
import { adjustHpUseCase } from "../adjust-hp-use-case.js";
import { advanceTurnUseCase } from "../advance-turn-use-case.js";
import { clearEncounterUseCase } from "../clear-encounter-use-case.js";
import { createPlayerCharacterUseCase } from "../create-player-character-use-case.js";
import { deletePlayerCharacterUseCase } from "../delete-player-character-use-case.js";
import { editCombatantUseCase } from "../edit-combatant-use-case.js";
import { editPlayerCharacterUseCase } from "../edit-player-character-use-case.js";
import { removeCombatantUseCase } from "../remove-combatant-use-case.js";
import { retreatTurnUseCase } from "../retreat-turn-use-case.js";
import { setAcUseCase } from "../set-ac-use-case.js";
import { setHpUseCase } from "../set-hp-use-case.js";
import { setInitiativeUseCase } from "../set-initiative-use-case.js";
import { toggleConcentrationUseCase } from "../toggle-concentration-use-case.js";
import { toggleConditionUseCase } from "../toggle-condition-use-case.js";
import {
requireSaved,
stubEncounterStore,
stubPlayerCharacterStore,
} from "./helpers.js";
const ID_A = combatantId("a");
function emptyEncounter() {
const result = createEncounter([]);
if (isDomainError(result)) throw new Error("Test setup failed");
return result;
}
function encounterWith(...names: string[]) {
let enc = emptyEncounter();
for (const name of names) {
const id = combatantId(name);
const store = stubEncounterStore(enc);
const result = addCombatantUseCase(store, id, name);
if (isDomainError(result)) throw new Error(`Setup failed: ${name}`);
enc = requireSaved(store.saved);
}
return enc;
}
function encounterWithHp(name: string, maxHp: number) {
const enc = encounterWith(name);
const store = stubEncounterStore(enc);
const id = combatantId(name);
setHpUseCase(store, id, maxHp);
return requireSaved(store.saved);
}
function createPc(name: string) {
const store = stubPlayerCharacterStore([]);
const id = playerCharacterId("pc-1");
createPlayerCharacterUseCase(store, id, name, 15, 40, undefined, undefined);
return { id, characters: requireSaved(store.saved) };
}
describe("addCombatantUseCase", () => {
it("adds a combatant and saves", () => {
const store = stubEncounterStore(emptyEncounter());
const result = addCombatantUseCase(store, ID_A, "Goblin");
expect(isDomainError(result)).toBe(false);
const saved = requireSaved(store.saved);
expect(saved.combatants).toHaveLength(1);
expect(saved.combatants[0].name).toBe("Goblin");
});
it("returns domain error for empty name", () => {
const store = stubEncounterStore(emptyEncounter());
const result = addCombatantUseCase(store, ID_A, "");
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("adjustHpUseCase", () => {
it("adjusts HP and saves", () => {
const enc = encounterWithHp("Goblin", 10);
const store = stubEncounterStore(enc);
const result = adjustHpUseCase(store, combatantId("Goblin"), -3);
expect(isDomainError(result)).toBe(false);
const saved = requireSaved(store.saved);
expect(saved.combatants[0].currentHp).toBe(7);
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = adjustHpUseCase(store, ID_A, -5);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("advanceTurnUseCase", () => {
it("advances turn and saves", () => {
const enc = encounterWith("A", "B");
const store = stubEncounterStore(enc);
const result = advanceTurnUseCase(store);
expect(isDomainError(result)).toBe(false);
const saved = requireSaved(store.saved);
expect(saved.activeIndex).toBe(1);
});
it("returns domain error on empty encounter", () => {
const store = stubEncounterStore(emptyEncounter());
const result = advanceTurnUseCase(store);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("clearEncounterUseCase", () => {
it("clears encounter and saves", () => {
const enc = encounterWith("Goblin");
const store = stubEncounterStore(enc);
const result = clearEncounterUseCase(store);
expect(isDomainError(result)).toBe(false);
const saved = requireSaved(store.saved);
expect(saved.combatants).toHaveLength(0);
});
});
describe("editCombatantUseCase", () => {
it("edits combatant name and saves", () => {
const enc = encounterWith("Goblin");
const store = stubEncounterStore(enc);
const result = editCombatantUseCase(
store,
combatantId("Goblin"),
"Hobgoblin",
);
expect(isDomainError(result)).toBe(false);
const saved = requireSaved(store.saved);
expect(saved.combatants[0].name).toBe("Hobgoblin");
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = editCombatantUseCase(store, ID_A, "X");
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("removeCombatantUseCase", () => {
it("removes combatant and saves", () => {
const enc = encounterWith("Goblin");
const store = stubEncounterStore(enc);
const result = removeCombatantUseCase(store, combatantId("Goblin"));
expect(isDomainError(result)).toBe(false);
const saved = requireSaved(store.saved);
expect(saved.combatants).toHaveLength(0);
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = removeCombatantUseCase(store, ID_A);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("retreatTurnUseCase", () => {
it("retreats turn and saves", () => {
const enc = encounterWith("A", "B");
const store1 = stubEncounterStore(enc);
advanceTurnUseCase(store1);
const store = stubEncounterStore(requireSaved(store1.saved));
const result = retreatTurnUseCase(store);
expect(isDomainError(result)).toBe(false);
expect(store.saved).not.toBeNull();
});
it("returns domain error on empty encounter", () => {
const store = stubEncounterStore(emptyEncounter());
const result = retreatTurnUseCase(store);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("setAcUseCase", () => {
it("sets AC and saves", () => {
const enc = encounterWith("Goblin");
const store = stubEncounterStore(enc);
const result = setAcUseCase(store, combatantId("Goblin"), 15);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].ac).toBe(15);
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = setAcUseCase(store, ID_A, 15);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("setHpUseCase", () => {
it("sets max HP and saves", () => {
const enc = encounterWith("Goblin");
const store = stubEncounterStore(enc);
const result = setHpUseCase(store, combatantId("Goblin"), 20);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].maxHp).toBe(20);
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = setHpUseCase(store, ID_A, 20);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("setInitiativeUseCase", () => {
it("sets initiative and saves", () => {
const enc = encounterWith("Goblin");
const store = stubEncounterStore(enc);
const result = setInitiativeUseCase(store, combatantId("Goblin"), 15);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(15);
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = setInitiativeUseCase(store, ID_A, 15);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("toggleConcentrationUseCase", () => {
it("toggles concentration and saves", () => {
const enc = encounterWith("Wizard");
const store = stubEncounterStore(enc);
const result = toggleConcentrationUseCase(store, combatantId("Wizard"));
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].isConcentrating).toBe(true);
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = toggleConcentrationUseCase(store, ID_A);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("toggleConditionUseCase", () => {
it("toggles condition and saves", () => {
const enc = encounterWith("Goblin");
const store = stubEncounterStore(enc);
const result = toggleConditionUseCase(
store,
combatantId("Goblin"),
"blinded" as ConditionId,
);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].conditions).toContain(
"blinded",
);
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = toggleConditionUseCase(
store,
ID_A,
"blinded" as ConditionId,
);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("createPlayerCharacterUseCase", () => {
it("creates a player character and saves", () => {
const store = stubPlayerCharacterStore([]);
const id = playerCharacterId("pc-1");
const result = createPlayerCharacterUseCase(
store,
id,
"Gandalf",
15,
40,
undefined,
undefined,
);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved)).toHaveLength(1);
});
it("returns domain error for invalid input", () => {
const store = stubPlayerCharacterStore([]);
const id = playerCharacterId("pc-1");
const result = createPlayerCharacterUseCase(
store,
id,
"",
15,
40,
undefined,
undefined,
);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("deletePlayerCharacterUseCase", () => {
it("deletes a player character and saves", () => {
const { id, characters } = createPc("Gandalf");
const store = stubPlayerCharacterStore(characters);
const result = deletePlayerCharacterUseCase(store, id);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved)).toHaveLength(0);
});
it("returns domain error for unknown character", () => {
const store = stubPlayerCharacterStore([]);
const result = deletePlayerCharacterUseCase(
store,
playerCharacterId("unknown"),
);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("editPlayerCharacterUseCase", () => {
it("edits a player character and saves", () => {
const { id, characters } = createPc("Gandalf");
const store = stubPlayerCharacterStore(characters);
const result = editPlayerCharacterUseCase(store, id, {
name: "Gandalf the White",
});
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved)[0].name).toBe("Gandalf the White");
});
it("returns domain error for unknown character", () => {
const store = stubPlayerCharacterStore([]);
const result = editPlayerCharacterUseCase(
store,
playerCharacterId("unknown"),
{ name: "X" },
);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,32 +5,47 @@ import {
type DomainError, type DomainError,
type DomainEvent, type DomainEvent,
isDomainError, isDomainError,
type RollMode,
rollInitiative, rollInitiative,
selectRoll,
setInitiative, setInitiative,
} from "@initiative/domain"; } from "@initiative/domain";
import type { EncounterStore } from "./ports.js"; import type { EncounterStore } from "./ports.js";
export interface RollAllResult {
events: DomainEvent[];
skippedNoSource: number;
}
export function rollAllInitiativeUseCase( export function rollAllInitiativeUseCase(
store: EncounterStore, store: EncounterStore,
rollDice: () => number, rollDice: () => number,
getCreature: (id: CreatureId) => Creature | undefined, getCreature: (id: CreatureId) => Creature | undefined,
): DomainEvent[] | DomainError { mode: RollMode = "normal",
): RollAllResult | DomainError {
let encounter = store.get(); let encounter = store.get();
const allEvents: DomainEvent[] = []; const allEvents: DomainEvent[] = [];
let skippedNoSource = 0;
for (const combatant of encounter.combatants) { for (const combatant of encounter.combatants) {
if (!combatant.creatureId) continue; if (!combatant.creatureId) continue;
if (combatant.initiative !== undefined) continue; if (combatant.initiative !== undefined) continue;
const creature = getCreature(combatant.creatureId); const creature = getCreature(combatant.creatureId);
if (!creature) continue; if (!creature) {
skippedNoSource++;
continue;
}
const { modifier } = calculateInitiative({ const { modifier } = calculateInitiative({
dexScore: creature.abilities.dex, dexScore: creature.abilities.dex,
cr: creature.cr, cr: creature.cr,
initiativeProficiency: creature.initiativeProficiency, initiativeProficiency: creature.initiativeProficiency,
}); });
const value = rollInitiative(rollDice(), modifier); const roll1 = rollDice();
const effectiveRoll =
mode === "normal" ? roll1 : selectRoll(roll1, rollDice(), mode);
const value = rollInitiative(effectiveRoll, modifier);
if (isDomainError(value)) { if (isDomainError(value)) {
return value; return value;
@@ -47,5 +62,5 @@ export function rollAllInitiativeUseCase(
} }
store.save(encounter); store.save(encounter);
return allEvents; return { events: allEvents, skippedNoSource };
} }

View File

@@ -6,7 +6,9 @@ import {
type DomainError, type DomainError,
type DomainEvent, type DomainEvent,
isDomainError, isDomainError,
type RollMode,
rollInitiative, rollInitiative,
selectRoll,
setInitiative, setInitiative,
} from "@initiative/domain"; } from "@initiative/domain";
import type { EncounterStore } from "./ports.js"; import type { EncounterStore } from "./ports.js";
@@ -14,8 +16,9 @@ import type { EncounterStore } from "./ports.js";
export function rollInitiativeUseCase( export function rollInitiativeUseCase(
store: EncounterStore, store: EncounterStore,
combatantId: CombatantId, combatantId: CombatantId,
diceRoll: number, diceRolls: readonly [number, ...number[]],
getCreature: (id: CreatureId) => Creature | undefined, getCreature: (id: CreatureId) => Creature | undefined,
mode: RollMode = "normal",
): DomainEvent[] | DomainError { ): DomainEvent[] | DomainError {
const encounter = store.get(); const encounter = store.get();
const combatant = encounter.combatants.find((c) => c.id === combatantId); const combatant = encounter.combatants.find((c) => c.id === combatantId);
@@ -50,7 +53,11 @@ export function rollInitiativeUseCase(
cr: creature.cr, cr: creature.cr,
initiativeProficiency: creature.initiativeProficiency, initiativeProficiency: creature.initiativeProficiency,
}); });
const value = rollInitiative(diceRoll, modifier); const effectiveRoll =
mode === "normal"
? diceRolls[0]
: selectRoll(diceRolls[0], diceRolls[1] ?? diceRolls[0], mode);
const value = rollInitiative(effectiveRoll, modifier);
if (isDomainError(value)) { if (isDomainError(value)) {
return value; return value;

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { addCombatant } from "../add-combatant.js"; import { addCombatant } from "../add-combatant.js";
import type { Combatant, Encounter } from "../types.js"; import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js"; import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers --- // --- Helpers ---
@@ -112,20 +113,14 @@ describe("addCombatant", () => {
const e = enc([A, B]); const e = enc([A, B]);
const result = addCombatant(e, combatantId("x"), ""); const result = addCombatant(e, combatantId("x"), "");
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-name");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
}); });
it("scenario 6: whitespace-only name returns error", () => { it("scenario 6: whitespace-only name returns error", () => {
const e = enc([A, B]); const e = enc([A, B]);
const result = addCombatant(e, combatantId("x"), " "); const result = addCombatant(e, combatantId("x"), " ");
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-name");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
}); });
}); });
@@ -146,12 +141,10 @@ describe("addCombatant", () => {
for (const e of scenarios) { for (const e of scenarios) {
const result = successResult(e, "new", "New"); const result = successResult(e, "new", "New");
const { combatants, activeIndex } = result.encounter; const { combatants, activeIndex } = result.encounter;
if (combatants.length > 0) { // After adding a combatant, list is always non-empty
expect(combatants.length).toBeGreaterThan(0);
expect(activeIndex).toBeGreaterThanOrEqual(0); expect(activeIndex).toBeGreaterThanOrEqual(0);
expect(activeIndex).toBeLessThan(combatants.length); expect(activeIndex).toBeLessThan(combatants.length);
} else {
expect(activeIndex).toBe(0);
}
} }
}); });
@@ -188,7 +181,7 @@ describe("addCombatant", () => {
it("INV-7: new combatant is always appended at the end", () => { it("INV-7: new combatant is always appended at the end", () => {
const e = enc([A, B]); const e = enc([A, B]);
const { encounter } = successResult(e, "C", "C"); const { encounter } = successResult(e, "C", "C");
expect(encounter.combatants[encounter.combatants.length - 1]).toEqual({ expect(encounter.combatants.at(-1)).toEqual({
id: combatantId("C"), id: combatantId("C"),
name: "C", name: "C",
}); });

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { adjustHp } from "../adjust-hp.js"; import { adjustHp } from "../adjust-hp.js";
import type { Combatant, Encounter } from "../types.js"; import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js"; import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant( function makeCombatant(
name: string, name: string,
@@ -101,37 +102,25 @@ describe("adjustHp", () => {
it("returns error for nonexistent combatant", () => { it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]); const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = adjustHp(e, combatantId("Z"), -1); const result = adjustHp(e, combatantId("Z"), -1);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "combatant-not-found");
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
}); });
it("returns error when combatant has no HP tracking", () => { it("returns error when combatant has no HP tracking", () => {
const e = enc([makeCombatant("A")]); const e = enc([makeCombatant("A")]);
const result = adjustHp(e, combatantId("A"), -1); const result = adjustHp(e, combatantId("A"), -1);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "no-hp-tracking");
if (isDomainError(result)) {
expect(result.code).toBe("no-hp-tracking");
}
}); });
it("returns error for zero delta", () => { it("returns error for zero delta", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]); const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = adjustHp(e, combatantId("A"), 0); const result = adjustHp(e, combatantId("A"), 0);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "zero-delta");
if (isDomainError(result)) {
expect(result.code).toBe("zero-delta");
}
}); });
it("returns error for non-integer delta", () => { it("returns error for non-integer delta", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]); const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = adjustHp(e, combatantId("A"), 1.5); const result = adjustHp(e, combatantId("A"), 1.5);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-delta");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-delta");
}
}); });
}); });

View File

@@ -7,6 +7,7 @@ import {
createEncounter, createEncounter,
type Encounter, type Encounter,
} from "../types.js"; } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers --- // --- Helpers ---
@@ -150,10 +151,7 @@ describe("advanceTurn", () => {
}; };
const result = advanceTurn(enc); const result = advanceTurn(enc);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-encounter");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-encounter");
}
}); });
it("scenario 8: three advances on [A,B,C] completes a full round cycle", () => { it("scenario 8: three advances on [A,B,C] completes a full round cycle", () => {

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { editCombatant } from "../edit-combatant.js"; import { editCombatant } from "../edit-combatant.js";
import type { Combatant, Encounter } from "../types.js"; import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js"; import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers --- // --- Helpers ---
@@ -124,40 +125,28 @@ describe("editCombatant", () => {
const e = enc([Alice, Bob]); const e = enc([Alice, Bob]);
const result = editCombatant(e, combatantId("nonexistent"), "NewName"); const result = editCombatant(e, combatantId("nonexistent"), "NewName");
expect(isDomainError(result)).toBe(true); expectDomainError(result, "combatant-not-found");
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
}); });
it("empty name returns invalid-name error", () => { it("empty name returns invalid-name error", () => {
const e = enc([Alice, Bob]); const e = enc([Alice, Bob]);
const result = editCombatant(e, combatantId("Alice"), ""); const result = editCombatant(e, combatantId("Alice"), "");
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-name");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
}); });
it("whitespace-only name returns invalid-name error", () => { it("whitespace-only name returns invalid-name error", () => {
const e = enc([Alice, Bob]); const e = enc([Alice, Bob]);
const result = editCombatant(e, combatantId("Alice"), " "); const result = editCombatant(e, combatantId("Alice"), " ");
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-name");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
}); });
it("empty encounter returns combatant-not-found for any id", () => { it("empty encounter returns combatant-not-found for any id", () => {
const e = enc([]); const e = enc([]);
const result = editCombatant(e, combatantId("any"), "Name"); const result = editCombatant(e, combatantId("any"), "Name");
expect(isDomainError(result)).toBe(true); expectDomainError(result, "combatant-not-found");
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
}); });
}); });
}); });

View File

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

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { removeCombatant } from "../remove-combatant.js"; import { removeCombatant } from "../remove-combatant.js";
import type { Combatant, Encounter } from "../types.js"; import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js"; import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers --- // --- Helpers ---
@@ -92,10 +93,7 @@ describe("removeCombatant", () => {
const e = enc([A, B], 0, 1); const e = enc([A, B], 0, 1);
const result = removeCombatant(e, combatantId("nonexistent")); const result = removeCombatant(e, combatantId("nonexistent"));
expect(isDomainError(result)).toBe(true); expectDomainError(result, "combatant-not-found");
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
}); });
}); });

View File

@@ -8,6 +8,7 @@ import {
type Encounter, type Encounter,
isDomainError, isDomainError,
} from "../types.js"; } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers --- // --- Helpers ---
@@ -83,10 +84,7 @@ describe("retreatTurn", () => {
const enc = encounter([A, B, C], 0, 1); const enc = encounter([A, B, C], 0, 1);
const result = retreatTurn(enc); const result = retreatTurn(enc);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "no-previous-turn");
if (isDomainError(result)) {
expect(result.code).toBe("no-previous-turn");
}
}); });
it("scenario 4: single-combatant retreat — wraps to same combatant, decrements round", () => { it("scenario 4: single-combatant retreat — wraps to same combatant, decrements round", () => {
@@ -117,10 +115,7 @@ describe("retreatTurn", () => {
}; };
const result = retreatTurn(enc); const result = retreatTurn(enc);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-encounter");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-encounter");
}
}); });
}); });

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { rollInitiative } from "../roll-initiative.js"; import { rollInitiative, selectRoll } from "../roll-initiative.js";
import { isDomainError } from "../types.js"; import { isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
describe("rollInitiative", () => { describe("rollInitiative", () => {
describe("valid rolls", () => { describe("valid rolls", () => {
@@ -32,18 +33,12 @@ describe("rollInitiative", () => {
describe("invalid dice rolls", () => { describe("invalid dice rolls", () => {
it("rejects 0", () => { it("rejects 0", () => {
const result = rollInitiative(0, 5); const result = rollInitiative(0, 5);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-dice-roll");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-dice-roll");
}
}); });
it("rejects 21", () => { it("rejects 21", () => {
const result = rollInitiative(21, 5); const result = rollInitiative(21, 5);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-dice-roll");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-dice-roll");
}
}); });
it("rejects non-integer (3.5)", () => { it("rejects non-integer (3.5)", () => {
@@ -68,3 +63,31 @@ describe("rollInitiative", () => {
}); });
}); });
}); });
describe("selectRoll", () => {
it("normal mode returns the first roll", () => {
expect(selectRoll(8, 15, "normal")).toBe(8);
});
it("advantage returns the higher roll", () => {
expect(selectRoll(8, 15, "advantage")).toBe(15);
});
it("advantage returns the higher roll (reversed)", () => {
expect(selectRoll(15, 8, "advantage")).toBe(15);
});
it("disadvantage returns the lower roll", () => {
expect(selectRoll(8, 15, "disadvantage")).toBe(8);
});
it("disadvantage returns the lower roll (reversed)", () => {
expect(selectRoll(15, 8, "disadvantage")).toBe(8);
});
it("equal rolls return the same value for all modes", () => {
expect(selectRoll(12, 12, "normal")).toBe(12);
expect(selectRoll(12, 12, "advantage")).toBe(12);
expect(selectRoll(12, 12, "disadvantage")).toBe(12);
});
});

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { setAc } from "../set-ac.js"; import { setAc } from "../set-ac.js";
import type { Combatant, Encounter } from "../types.js"; import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js"; import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant(name: string, ac?: number): Combatant { function makeCombatant(name: string, ac?: number): Combatant {
return ac === undefined return ac === undefined
@@ -67,30 +68,21 @@ describe("setAc", () => {
const e = enc([makeCombatant("A")]); const e = enc([makeCombatant("A")]);
const result = setAc(e, combatantId("nonexistent"), 10); const result = setAc(e, combatantId("nonexistent"), 10);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "combatant-not-found");
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
}); });
it("returns error for negative AC", () => { it("returns error for negative AC", () => {
const e = enc([makeCombatant("A")]); const e = enc([makeCombatant("A")]);
const result = setAc(e, combatantId("A"), -1); const result = setAc(e, combatantId("A"), -1);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-ac");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
}); });
it("returns error for non-integer AC", () => { it("returns error for non-integer AC", () => {
const e = enc([makeCombatant("A")]); const e = enc([makeCombatant("A")]);
const result = setAc(e, combatantId("A"), 3.5); const result = setAc(e, combatantId("A"), 3.5);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-ac");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
}); });
it("returns error for NaN", () => { it("returns error for NaN", () => {

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { setHp } from "../set-hp.js"; import { setHp } from "../set-hp.js";
import type { Combatant, Encounter } from "../types.js"; import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js"; import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant( function makeCombatant(
name: string, name: string,
@@ -10,9 +11,9 @@ function makeCombatant(
return { return {
id: combatantId(name), id: combatantId(name),
name, name,
...(opts?.maxHp !== undefined ...(opts?.maxHp === undefined
? { maxHp: opts.maxHp, currentHp: opts.currentHp ?? opts.maxHp } ? {}
: {}), : { maxHp: opts.maxHp, currentHp: opts.currentHp ?? opts.maxHp }),
}; };
} }
@@ -116,37 +117,25 @@ describe("setHp", () => {
it("returns error for nonexistent combatant", () => { it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A")]); const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("Z"), 10); const result = setHp(e, combatantId("Z"), 10);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "combatant-not-found");
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
}); });
it("rejects maxHp of 0", () => { it("rejects maxHp of 0", () => {
const e = enc([makeCombatant("A")]); const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("A"), 0); const result = setHp(e, combatantId("A"), 0);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-max-hp");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
}); });
it("rejects negative maxHp", () => { it("rejects negative maxHp", () => {
const e = enc([makeCombatant("A")]); const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("A"), -5); const result = setHp(e, combatantId("A"), -5);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-max-hp");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
}); });
it("rejects non-integer maxHp", () => { it("rejects non-integer maxHp", () => {
const e = enc([makeCombatant("A")]); const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("A"), 3.5); const result = setHp(e, combatantId("A"), 3.5);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-max-hp");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
}); });
}); });

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { setInitiative } from "../set-initiative.js"; import { setInitiative } from "../set-initiative.js";
import type { Combatant, Encounter } from "../types.js"; import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js"; import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers --- // --- Helpers ---
@@ -73,10 +74,7 @@ describe("setInitiative", () => {
const e = enc([A, B], 0); const e = enc([A, B], 0);
const result = setInitiative(e, combatantId("A"), 3.5); const result = setInitiative(e, combatantId("A"), 3.5);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-initiative");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-initiative");
}
}); });
it("AS-3b: reject NaN", () => { it("AS-3b: reject NaN", () => {
@@ -109,10 +107,7 @@ describe("setInitiative", () => {
const e = enc([A, B], 0); const e = enc([A, B], 0);
const result = setInitiative(e, combatantId("nonexistent"), 10); const result = setInitiative(e, combatantId("nonexistent"), 10);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "combatant-not-found");
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
}); });
}); });

View File

@@ -0,0 +1,9 @@
import { expect } from "vitest";
import { type DomainError, isDomainError } from "../types.js";
export function expectDomainError(result: unknown, code: string): DomainError {
expect(isDomainError(result)).toBe(true);
if (!isDomainError(result)) throw new Error("unreachable");
expect(result.code).toBe(code);
return result;
}

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { toggleConcentration } from "../toggle-concentration.js"; import { toggleConcentration } from "../toggle-concentration.js";
import type { Combatant, Encounter } from "../types.js"; import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js"; import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant(name: string, isConcentrating?: boolean): Combatant { function makeCombatant(name: string, isConcentrating?: boolean): Combatant {
return isConcentrating return isConcentrating
@@ -46,10 +47,7 @@ describe("toggleConcentration", () => {
const e = enc([makeCombatant("A")]); const e = enc([makeCombatant("A")]);
const result = toggleConcentration(e, combatantId("missing")); const result = toggleConcentration(e, combatantId("missing"));
expect(isDomainError(result)).toBe(true); expectDomainError(result, "combatant-not-found");
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
}); });
it("does not mutate input encounter", () => { it("does not mutate input encounter", () => {

View File

@@ -4,6 +4,7 @@ import { CONDITION_DEFINITIONS } from "../conditions.js";
import { toggleCondition } from "../toggle-condition.js"; import { toggleCondition } from "../toggle-condition.js";
import type { Combatant, Encounter } from "../types.js"; import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js"; import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant( function makeCombatant(
name: string, name: string,
@@ -77,20 +78,14 @@ describe("toggleCondition", () => {
"flying" as ConditionId, "flying" as ConditionId,
); );
expect(isDomainError(result)).toBe(true); expectDomainError(result, "unknown-condition");
if (isDomainError(result)) {
expect(result.code).toBe("unknown-condition");
}
}); });
it("returns error for nonexistent combatant", () => { it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A")]); const e = enc([makeCombatant("A")]);
const result = toggleCondition(e, combatantId("missing"), "blinded"); const result = toggleCondition(e, combatantId("missing"), "blinded");
expect(isDomainError(result)).toBe(true); expectDomainError(result, "combatant-not-found");
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
}); });
it("does not mutate input encounter", () => { it("does not mutate input encounter", () => {

View File

@@ -1,6 +1,5 @@
import type { DomainEvent } from "./events.js"; import type { DomainEvent } from "./events.js";
import type { DomainError, Encounter } from "./types.js"; import type { DomainError, Encounter } from "./types.js";
import { isDomainError } from "./types.js";
interface AdvanceTurnSuccess { interface AdvanceTurnSuccess {
readonly encounter: Encounter; readonly encounter: Encounter;
@@ -62,4 +61,4 @@ export function advanceTurn(
}; };
} }
export { isDomainError }; export { isDomainError } from "./types.js";

View File

@@ -23,7 +23,10 @@ export function resolveCreatureName(
if (name === baseName) { if (name === baseName) {
exactMatches.push(i); exactMatches.push(i);
} else { } else {
const match = new RegExp(`^${escapeRegExp(baseName)} (\\d+)$`).exec(name); const match = new RegExp(
String.raw`^${escapeRegExp(baseName)} (\d+)$`,
).exec(name);
// biome-ignore lint/nursery/noUnnecessaryConditions: RegExp.exec() returns null on no match — false positive
if (match) { if (match) {
const num = Number.parseInt(match[1], 10); const num = Number.parseInt(match[1], 10);
if (num > maxNumber) maxNumber = num; if (num > maxNumber) maxNumber = num;
@@ -50,5 +53,5 @@ export function resolveCreatureName(
} }
function escapeRegExp(s: string): string { function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return s.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,10 @@ export {
type ConditionId, type ConditionId,
VALID_CONDITION_IDS, VALID_CONDITION_IDS,
} from "./conditions.js"; } from "./conditions.js";
export {
type CreatePlayerCharacterSuccess,
createPlayerCharacter,
} from "./create-player-character.js";
export { export {
type BestiaryIndex, type BestiaryIndex,
type BestiaryIndexEntry, type BestiaryIndexEntry,
@@ -25,10 +29,18 @@ export {
type SpellcastingBlock, type SpellcastingBlock,
type TraitBlock, type TraitBlock,
} from "./creature-types.js"; } from "./creature-types.js";
export {
type DeletePlayerCharacterSuccess,
deletePlayerCharacter,
} from "./delete-player-character.js";
export { export {
type EditCombatantSuccess, type EditCombatantSuccess,
editCombatant, editCombatant,
} from "./edit-combatant.js"; } from "./edit-combatant.js";
export {
type EditPlayerCharacterSuccess,
editPlayerCharacter,
} from "./edit-player-character.js";
export type { export type {
AcSet, AcSet,
CombatantAdded, CombatantAdded,
@@ -43,6 +55,9 @@ export type {
EncounterCleared, EncounterCleared,
InitiativeSet, InitiativeSet,
MaxHpSet, MaxHpSet,
PlayerCharacterCreated,
PlayerCharacterDeleted,
PlayerCharacterUpdated,
RoundAdvanced, RoundAdvanced,
RoundRetreated, RoundRetreated,
TurnAdvanced, TurnAdvanced,
@@ -54,12 +69,26 @@ export {
formatInitiativeModifier, formatInitiativeModifier,
type InitiativeResult, type InitiativeResult,
} from "./initiative.js"; } from "./initiative.js";
export {
type PlayerCharacter,
type PlayerCharacterId,
type PlayerCharacterList,
type PlayerColor,
type PlayerIcon,
playerCharacterId,
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
} from "./player-character-types.js";
export { export {
type RemoveCombatantSuccess, type RemoveCombatantSuccess,
removeCombatant, removeCombatant,
} from "./remove-combatant.js"; } from "./remove-combatant.js";
export { retreatTurn } from "./retreat-turn.js"; export { retreatTurn } from "./retreat-turn.js";
export { rollInitiative } from "./roll-initiative.js"; export {
type RollMode,
rollInitiative,
selectRoll,
} from "./roll-initiative.js";
export { type SetAcSuccess, setAc } from "./set-ac.js"; export { type SetAcSuccess, setAc } from "./set-ac.js";
export { type SetHpSuccess, setHp } from "./set-hp.js"; export { type SetHpSuccess, setHp } from "./set-hp.js";
export { export {

View File

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

View File

@@ -1,5 +1,21 @@
import type { DomainError } from "./types.js"; import type { DomainError } from "./types.js";
export type RollMode = "normal" | "advantage" | "disadvantage";
/**
* Selects the effective roll from two dice values based on the roll mode.
* Advantage takes the higher, disadvantage takes the lower.
*/
export function selectRoll(
roll1: number,
roll2: number,
mode: RollMode,
): number {
if (mode === "advantage") return Math.max(roll1, roll2);
if (mode === "disadvantage") return Math.min(roll1, roll2);
return roll1;
}
/** /**
* Pure function that computes initiative from a resolved dice roll and modifier. * Pure function that computes initiative from a resolved dice roll and modifier.
* The dice roll must be an integer in [1, 20]. * The dice roll must be an integer in [1, 20].

View File

@@ -21,15 +21,13 @@ export function setAc(
}; };
} }
if (value !== undefined) { if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
if (!Number.isInteger(value) || value < 0) {
return { return {
kind: "domain-error", kind: "domain-error",
code: "invalid-ac", code: "invalid-ac",
message: `AC must be a non-negative integer, got ${value}`, message: `AC must be a non-negative integer, got ${value}`,
}; };
} }
}
const target = encounter.combatants[targetIdx]; const target = encounter.combatants[targetIdx];
const previousAc = target.ac; const previousAc = target.ac;

View File

@@ -28,15 +28,13 @@ export function setHp(
}; };
} }
if (maxHp !== undefined) { if (maxHp !== undefined && (!Number.isInteger(maxHp) || maxHp < 1)) {
if (!Number.isInteger(maxHp) || maxHp < 1) {
return { return {
kind: "domain-error", kind: "domain-error",
code: "invalid-max-hp", code: "invalid-max-hp",
message: `Max HP must be a positive integer, got ${maxHp}`, message: `Max HP must be a positive integer, got ${maxHp}`,
}; };
} }
}
const target = encounter.combatants[targetIdx]; const target = encounter.combatants[targetIdx];
const previousMaxHp = target.maxHp; const previousMaxHp = target.maxHp;

View File

@@ -65,7 +65,7 @@ export function setInitiative(
const aInit = a.c.initiative as number; const aInit = a.c.initiative as number;
const bInit = b.c.initiative as number; const bInit = b.c.initiative as number;
const diff = bInit - aInit; const diff = bInit - aInit;
return diff !== 0 ? diff : a.i - b.i; return diff === 0 ? a.i - b.i : diff;
} }
if (aHas && !bHas) return -1; if (aHas && !bHas) return -1;
if (!aHas && bHas) return 1; if (!aHas && bHas) return 1;

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