31 Commits
0.6.0 ... 0.7.4

Author SHA1 Message Date
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
86 changed files with 3552 additions and 1420 deletions

1
.gitignore vendored
View File

@@ -11,3 +11,4 @@ Thumbs.db
.idea/
coverage/
*.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
```bash
pnpm check # Merge gate — must pass before every commit (audit + knip + biome + typecheck + test/coverage + jscpd)
pnpm check # Merge gate — must pass before every commit (audit + knip + biome + oxlint + typecheck + test/coverage + jscpd)
pnpm oxlint # Type-aware linting (oxlint — complements Biome)
pnpm knip # Unused code detection (Knip)
pnpm test # Run all tests (Vitest)
pnpm test:watch # Tests in watch mode
@@ -58,12 +59,13 @@ docs/agents/ RPI skill artifacts (research reports, plans)
- React 19, Vite 6, Tailwind CSS v4
- Lucide React (icons)
- `idb` (IndexedDB wrapper for bestiary cache)
- Biome 2.0 (formatting + linting), Knip (unused code), jscpd (copy-paste detection)
- Biome 2.4 (formatting + linting), oxlint (type-aware linting), Knip (unused code), jscpd (copy-paste detection)
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
## Conventions
- **Biome 2.0** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
- **Biome 2.4** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
- **oxlint** for type-aware linting that Biome can't do (unnecessary type assertions, deprecated APIs, `replaceAll` preference, `String.raw`). Configured in `.oxlintrc.json`.
- **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports when required by the repo's ESM settings (e.g., `./types.js`).
- **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical.
- **Domain events** are plain data objects with a `type` discriminant — no classes.
@@ -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`.
- **Quality gates** are enforced at pre-commit via Lefthook's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process.
## Self-Review Checklist
Before finishing a change, consider:
- Is this the simplest approach that solves the current problem?
- Is there duplication that hurts readability? (But don't abstract prematurely.)
- Are errors handled correctly and communicated sensibly to the user?
- Does the UI follow modern patterns and feel intuitive to interact with?
## Speckit Workflow
Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Specs are **living documents** that describe features, not individual changes.

View File

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

View File

@@ -2,7 +2,12 @@ import {
rollAllInitiativeUseCase,
rollInitiativeUseCase,
} from "@initiative/application";
import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
import {
type CombatantId,
type Creature,
type CreatureId,
isDomainError,
} from "@initiative/domain";
import {
useCallback,
useEffect,
@@ -11,10 +16,12 @@ import {
useState,
} from "react";
import { ActionBar } from "./components/action-bar";
import { BulkImportToasts } from "./components/bulk-import-toasts";
import { CombatantRow } from "./components/combatant-row";
import { CreatePlayerModal } from "./components/create-player-modal";
import { PlayerManagement } from "./components/player-management";
import { SourceManager } from "./components/source-manager";
import {
PlayerCharacterSection,
type PlayerCharacterSectionHandle,
} from "./components/player-character-section";
import { StatBlockPanel } from "./components/stat-block-panel";
import { Toast } from "./components/toast";
import { TurnNavigation } from "./components/turn-navigation";
@@ -22,6 +29,7 @@ import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
import { useBulkImport } from "./hooks/use-bulk-import";
import { useEncounter } from "./hooks/use-encounter";
import { usePlayerCharacters } from "./hooks/use-player-characters";
import { useSidePanelState } from "./hooks/use-side-panel-state";
function rollDice(): number {
return Math.floor(Math.random() * 20) + 1;
@@ -47,11 +55,10 @@ function useActionBarAnimation(combatantCount: number) {
const empty = combatantCount === 0;
const risingClass = rising ? " animate-rise-to-center" : "";
const settlingClass = settling ? " animate-settle-to-bottom" : "";
const topBarClass = settling
? " animate-slide-down-in"
: topBarExiting
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 {
@@ -68,6 +75,9 @@ function useActionBarAnimation(combatantCount: number) {
export function App() {
const {
encounter,
isEmpty,
hasCreatureCombatants,
canRollAllInitiative,
advanceTurn,
retreatTurn,
addCombatant,
@@ -92,12 +102,6 @@ export function App() {
deleteCharacter: deletePlayerCharacter,
} = usePlayerCharacters();
const [createPlayerOpen, setCreatePlayerOpen] = useState(false);
const [managementOpen, setManagementOpen] = useState(false);
const [editingPlayer, setEditingPlayer] = useState<
(typeof playerCharacters)[number] | undefined
>(undefined);
const {
search,
getCreature,
@@ -109,32 +113,16 @@ export function App() {
} = useBestiary();
const bulkImport = useBulkImport();
const sidePanel = useSidePanelState();
const [selectedCreatureId, setSelectedCreatureId] =
useState<CreatureId | null>(null);
const [bulkImportMode, setBulkImportMode] = useState(false);
const [sourceManagerOpen, setSourceManagerOpen] = useState(false);
const [isRightPanelFolded, setIsRightPanelFolded] = useState(false);
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
null,
);
const [isWideDesktop, setIsWideDesktop] = useState(
() => window.matchMedia("(min-width: 1280px)").matches,
);
const [rollSkippedCount, setRollSkippedCount] = useState(0);
useEffect(() => {
const mq = window.matchMedia("(min-width: 1280px)");
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const selectedCreature: Creature | null = selectedCreatureId
? (getCreature(selectedCreatureId) ?? null)
const selectedCreature: Creature | null = sidePanel.selectedCreatureId
? (getCreature(sidePanel.selectedCreatureId) ?? null)
: null;
const pinnedCreature: Creature | null = pinnedCreatureId
? (getCreature(pinnedCreatureId) ?? null)
const pinnedCreature: Creature | null = sidePanel.pinnedCreatureId
? (getCreature(sidePanel.pinnedCreatureId) ?? null)
: null;
const handleAddFromBestiary = useCallback(
@@ -144,10 +132,12 @@ export function App() {
[addFromBestiary],
);
const handleCombatantStatBlock = useCallback((creatureId: string) => {
setSelectedCreatureId(creatureId as CreatureId);
setIsRightPanelFolded(false);
}, []);
const handleCombatantStatBlock = useCallback(
(creatureId: string) => {
sidePanel.showCreature(creatureId as CreatureId);
},
[sidePanel.showCreature],
);
const handleRollInitiative = useCallback(
(id: CombatantId) => {
@@ -157,23 +147,23 @@ export function App() {
);
const handleRollAllInitiative = useCallback(() => {
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
const result = rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
if (!isDomainError(result) && result.skippedNoSource > 0) {
setRollSkippedCount(result.skippedNoSource);
}
}, [makeStore, getCreature]);
const handleViewStatBlock = useCallback((result: SearchResult) => {
const handleViewStatBlock = useCallback(
(result: SearchResult) => {
const slug = result.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/(^-|-$)/g, "");
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
setSelectedCreatureId(cId);
setIsRightPanelFolded(false);
}, []);
const handleBulkImport = useCallback(() => {
setBulkImportMode(true);
setSelectedCreatureId(null);
}, []);
sidePanel.showCreature(cId);
},
[sidePanel.showCreature],
);
const handleStartBulkImport = useCallback(
(baseUrl: string) => {
@@ -188,32 +178,12 @@ export function App() {
);
const handleBulkImportDone = useCallback(() => {
setBulkImportMode(false);
sidePanel.dismissPanel();
bulkImport.reset();
}, [bulkImport.reset]);
const handleDismissBrowsePanel = useCallback(() => {
setSelectedCreatureId(null);
setBulkImportMode(false);
}, []);
const handleToggleFold = useCallback(() => {
setIsRightPanelFolded((f) => !f);
}, []);
const handlePin = useCallback(() => {
if (selectedCreatureId) {
setPinnedCreatureId((prev) =>
prev === selectedCreatureId ? null : selectedCreatureId,
);
}
}, [selectedCreatureId]);
const handleUnpin = useCallback(() => {
setPinnedCreatureId(null);
}, []);
}, [sidePanel.dismissPanel, bulkImport.reset]);
const actionBarInputRef = useRef<HTMLInputElement>(null);
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
// Auto-scroll to the active combatant when the turn changes
@@ -223,7 +193,7 @@ export function App() {
block: "nearest",
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.
@@ -232,21 +202,21 @@ export function App() {
useEffect(() => {
if (prevActiveIndexRef.current === encounter.activeIndex) return;
prevActiveIndexRef.current = encounter.activeIndex;
if (!window.matchMedia("(min-width: 1024px)").matches) return;
if (!globalThis.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]);
const isEmpty = encounter.combatants.length === 0;
const showRollAllInitiative = encounter.combatants.some(
(c) => c.creatureId != null && c.initiative == null,
);
sidePanel.showCreature(active.creatureId);
}, [
encounter.activeIndex,
encounter.combatants,
isLoaded,
sidePanel.showCreature,
]);
return (
<div className="flex h-screen flex-col">
<div className="relative mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0">
{actionBarAnim.showTopBar && (
<div className="relative mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-3 px-4">
{!!actionBarAnim.showTopBar && (
<div
className={`shrink-0 pt-8${actionBarAnim.topBarClass}`}
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
@@ -262,7 +232,7 @@ export function App() {
{isEmpty ? (
/* Empty state — ActionBar centered */
<div className="flex flex-1 items-center justify-center min-h-0 pb-[15%] pt-8">
<div className="flex min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]">
<div
className={`w-full${actionBarAnim.risingClass}`}
onAnimationEnd={actionBarAnim.onRiseEnd}
@@ -273,29 +243,26 @@ export function App() {
bestiarySearch={search}
bestiaryLoaded={isLoaded}
onViewStatBlock={handleViewStatBlock}
onBulkImport={handleBulkImport}
onBulkImport={sidePanel.showBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"}
inputRef={actionBarInputRef}
playerCharacters={playerCharacters}
onAddFromPlayerCharacter={addFromPlayerCharacter}
onManagePlayers={() => setManagementOpen(true)}
onManagePlayers={() =>
playerCharacterRef.current?.openManagement()
}
onRollAllInitiative={handleRollAllInitiative}
showRollAllInitiative={showRollAllInitiative}
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
showRollAllInitiative={hasCreatureCombatants}
rollAllInitiativeDisabled={!canRollAllInitiative}
onOpenSourceManager={sidePanel.showSourceManager}
autoFocus
/>
</div>
</div>
) : (
<>
{sourceManagerOpen && (
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
<SourceManager onCacheCleared={refreshCache} />
</div>
)}
{/* Scrollable area — combatant list */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="flex flex-col px-2 py-2">
{encounter.combatants.map((c, i) => (
<CombatantRow
@@ -335,15 +302,18 @@ export function App() {
bestiarySearch={search}
bestiaryLoaded={isLoaded}
onViewStatBlock={handleViewStatBlock}
onBulkImport={handleBulkImport}
onBulkImport={sidePanel.showBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"}
inputRef={actionBarInputRef}
playerCharacters={playerCharacters}
onAddFromPlayerCharacter={addFromPlayerCharacter}
onManagePlayers={() => setManagementOpen(true)}
onManagePlayers={() =>
playerCharacterRef.current?.openManagement()
}
onRollAllInitiative={handleRollAllInitiative}
showRollAllInitiative={showRollAllInitiative}
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
showRollAllInitiative={hasCreatureCombatants}
rollAllInitiativeDisabled={!canRollAllInitiative}
onOpenSourceManager={sidePanel.showSourceManager}
/>
</div>
</>
@@ -351,19 +321,19 @@ export function App() {
</div>
{/* Pinned Stat Block Panel (left) */}
{pinnedCreatureId && isWideDesktop && (
{!!sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
<StatBlockPanel
creatureId={pinnedCreatureId}
creatureId={sidePanel.pinnedCreatureId}
creature={pinnedCreature}
isSourceCached={isSourceCached}
fetchAndCacheSource={fetchAndCacheSource}
uploadAndCacheSource={uploadAndCacheSource}
refreshCache={refreshCache}
panelRole="pinned"
isFolded={false}
onToggleFold={() => {}}
isCollapsed={false}
onToggleCollapse={() => {}}
onPin={() => {}}
onUnpin={handleUnpin}
onUnpin={sidePanel.unpin}
showPinButton={false}
side="left"
onDismiss={() => {}}
@@ -372,90 +342,47 @@ export function App() {
{/* Browse Stat Block Panel (right) */}
<StatBlockPanel
creatureId={selectedCreatureId}
creatureId={sidePanel.selectedCreatureId}
creature={selectedCreature}
isSourceCached={isSourceCached}
fetchAndCacheSource={fetchAndCacheSource}
uploadAndCacheSource={uploadAndCacheSource}
refreshCache={refreshCache}
panelRole="browse"
isFolded={isRightPanelFolded}
onToggleFold={handleToggleFold}
onPin={handlePin}
isCollapsed={sidePanel.isRightPanelCollapsed}
onToggleCollapse={sidePanel.toggleCollapse}
onPin={sidePanel.togglePin}
onUnpin={() => {}}
showPinButton={isWideDesktop && !!selectedCreature}
showPinButton={sidePanel.isWideDesktop && !!selectedCreature}
side="right"
onDismiss={handleDismissBrowsePanel}
bulkImportMode={bulkImportMode}
onDismiss={sidePanel.dismissPanel}
bulkImportMode={sidePanel.bulkImportMode}
bulkImportState={bulkImport.state}
onStartBulkImport={handleStartBulkImport}
onBulkImportDone={handleBulkImportDone}
sourceManagerMode={sidePanel.sourceManagerMode}
/>
{/* Toast for bulk import progress when panel is closed */}
{bulkImport.state.status === "loading" && !bulkImportMode && (
<Toast
message={`Loading sources... ${bulkImport.state.completed + bulkImport.state.failed}/${bulkImport.state.total}`}
progress={
bulkImport.state.total > 0
? (bulkImport.state.completed + bulkImport.state.failed) /
bulkImport.state.total
: 0
}
onDismiss={() => {}}
<BulkImportToasts
state={bulkImport.state}
visible={!sidePanel.bulkImportMode || sidePanel.isRightPanelCollapsed}
onReset={bulkImport.reset}
/>
)}
{bulkImport.state.status === "complete" && !bulkImportMode && (
{rollSkippedCount > 0 && (
<Toast
message="All sources loaded"
onDismiss={bulkImport.reset}
autoDismissMs={3000}
/>
)}
{bulkImport.state.status === "partial-failure" && !bulkImportMode && (
<Toast
message={`Loaded ${bulkImport.state.completed}/${bulkImport.state.total} sources (${bulkImport.state.failed} failed)`}
onDismiss={bulkImport.reset}
message={`${rollSkippedCount} skipped — bestiary source not loaded`}
onDismiss={() => setRollSkippedCount(0)}
autoDismissMs={4000}
/>
)}
<CreatePlayerModal
open={createPlayerOpen}
onClose={() => {
setCreatePlayerOpen(false);
setEditingPlayer(undefined);
}}
onSave={(name, ac, maxHp, color, icon) => {
if (editingPlayer) {
editPlayerCharacter?.(editingPlayer.id, {
name,
ac,
maxHp,
color,
icon,
});
} else {
createPlayerCharacter(name, ac, maxHp, color, icon);
}
}}
playerCharacter={editingPlayer}
/>
<PlayerManagement
open={managementOpen}
onClose={() => setManagementOpen(false)}
<PlayerCharacterSection
ref={playerCharacterRef}
characters={playerCharacters}
onEdit={(pc) => {
setEditingPlayer(pc);
setCreatePlayerOpen(true);
setManagementOpen(false);
}}
onDelete={(id) => deletePlayerCharacter?.(id)}
onCreate={() => {
setEditingPlayer(undefined);
setCreatePlayerOpen(true);
setManagementOpen(false);
}}
onCreateCharacter={createPlayerCharacter}
onEditCharacter={editPlayerCharacter}
onDeleteCharacter={deletePlayerCharacter}
/>
</div>
);

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,8 @@ import type {
import { creatureId, proficiencyBonus } from "@initiative/domain";
import { stripTags } from "./strip-tags.js";
const LEADING_DIGITS_REGEX = /^(\d+)/;
// --- Raw 5etools types (minimal, for parsing) ---
interface RawMonster {
@@ -168,7 +170,7 @@ function extractAc(ac: RawMonster["ac"]): {
}
if ("special" in first) {
// Variable AC (e.g. spell summons) — parse leading number if possible
const match = first.special.match(/^(\d+)/);
const match = LEADING_DIGITS_REGEX.exec(first.special);
return {
value: match ? Number(match[1]) : 0,
source: first.special,
@@ -371,8 +373,8 @@ function extractCr(cr: string | { cr: string } | undefined): string {
function makeCreatureId(source: string, name: string): CreatureId {
const slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/(^-|-$)/g, "");
return creatureId(`${source.toLowerCase()}:${slug}`);
}

View File

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

View File

@@ -0,0 +1,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-l-accent");
});
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

@@ -12,7 +12,7 @@ export function AcShield({ value, onClick, className }: AcShieldProps) {
type="button"
onClick={onClick}
className={cn(
"relative inline-flex items-center justify-center text-sm tabular-nums text-muted-foreground transition-colors hover:text-hover-neutral",
"relative inline-flex items-center justify-center text-muted-foreground text-sm tabular-nums transition-colors hover:text-hover-neutral",
className,
)}
style={{ width: 28, height: 32 }}
@@ -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" />
</svg>
<span className="relative text-xs font-medium leading-none">
{value !== undefined ? value : "\u2014"}
<span className="relative font-medium text-xs leading-none">
{value == null ? "\u2014" : String(value)}
</span>
</button>
);

View File

@@ -1,4 +1,4 @@
import type { PlayerCharacter, PlayerIcon } from "@initiative/domain";
import type { PlayerCharacter } from "@initiative/domain";
import {
Check,
Eye,
@@ -9,7 +9,7 @@ import {
Plus,
Users,
} from "lucide-react";
import { type FormEvent, type RefObject, useState } from "react";
import React, { type RefObject, useDeferredValue, useState } from "react";
import type { SearchResult } from "../hooks/use-bestiary.js";
import { cn } from "../lib/utils.js";
import { D20Icon } from "./d20-icon.js";
@@ -40,6 +40,7 @@ interface ActionBarProps {
onManagePlayers?: () => void;
onRollAllInitiative?: () => void;
showRollAllInitiative?: boolean;
rollAllInitiativeDisabled?: boolean;
onOpenSourceManager?: () => void;
autoFocus?: boolean;
}
@@ -60,60 +61,63 @@ function AddModeSuggestions({
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="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
<button
type="button"
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-2 text-left text-sm text-accent hover:bg-accent/20"
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-xs text-muted-foreground">
<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 text-xs font-medium text-muted-foreground">
<div className="px-3 py-1 font-medium text-muted-foreground text-xs">
Players
</div>
<ul>
{pcMatches.map((pc) => {
const PcIcon = PLAYER_ICON_MAP[pc.icon as PlayerIcon];
const pcColor =
PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX];
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-sm text-foreground hover:bg-hover-neutral-bg"
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);
onDismiss();
onClear();
}}
>
{PcIcon && (
{!!PcIcon && (
<PcIcon size={14} style={{ color: pcColor }} />
)}
<span className="flex-1 truncate">{pc.name}</span>
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground text-xs">
Player
</span>
</button>
@@ -133,19 +137,18 @@ function AddModeSuggestions({
<li key={key}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
isQueued
? "bg-accent/30 text-foreground"
: i === suggestionIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
}`}
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${(() => {
if (isQueued) return "bg-accent/30 text-foreground";
if (i === suggestionIndex)
return "bg-accent/20 text-foreground";
return "text-foreground hover:bg-hover-neutral-bg";
})()}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onClickSuggestion(result)}
onMouseEnter={() => onSetSuggestionIndex(i)}
>
<span>{result.name}</span>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1 text-muted-foreground text-xs">
{isQueued ? (
<>
<button
@@ -235,7 +238,7 @@ function buildOverflowItems(opts: {
if (opts.bestiaryLoaded && opts.onBulkImport) {
items.push({
icon: <Import className="h-4 w-4" />,
label: "Bulk Import",
label: "Import All Sources",
onClick: opts.onBulkImport,
disabled: opts.bulkImportDisabled,
});
@@ -257,12 +260,15 @@ export function ActionBar({
onManagePlayers,
onRollAllInitiative,
showRollAllInitiative,
rollAllInitiativeDisabled,
onOpenSourceManager,
autoFocus,
}: ActionBarProps) {
}: Readonly<ActionBarProps>) {
const [nameInput, setNameInput] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
const deferredSuggestions = useDeferredValue(suggestions);
const deferredPcMatches = useDeferredValue(pcMatches);
const [suggestionIndex, setSuggestionIndex] = useState(-1);
const [queued, setQueued] = useState<QueuedCreature | null>(null);
const [customInit, setCustomInit] = useState("");
@@ -284,6 +290,13 @@ export function ActionBar({
setSuggestionIndex(-1);
};
const dismissSuggestions = () => {
setSuggestions([]);
setPcMatches([]);
setQueued(null);
setSuggestionIndex(-1);
};
const confirmQueued = () => {
if (!queued) return;
for (let i = 0; i < queued.count; i++) {
@@ -298,7 +311,7 @@ export function ActionBar({
return Number.isNaN(n) ? undefined : n;
};
const handleAdd = (e: FormEvent) => {
const handleAdd = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
if (browseMode) return;
if (queued) {
@@ -380,7 +393,8 @@ export function ActionBar({
}
};
const hasSuggestions = suggestions.length > 0 || pcMatches.length > 0;
const hasSuggestions =
deferredSuggestions.length > 0 || deferredPcMatches.length > 0;
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!hasSuggestions) return;
@@ -395,7 +409,7 @@ export function ActionBar({
e.preventDefault();
handleEnter();
} else if (e.key === "Escape") {
clearInput();
dismissSuggestions();
}
};
@@ -460,12 +474,12 @@ export function ActionBar({
className="pr-8"
autoFocus={autoFocus}
/>
{bestiaryLoaded && onViewStatBlock && (
{bestiaryLoaded && !!onViewStatBlock && (
<button
type="button"
tabIndex={-1}
className={cn(
"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
"absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
browseMode && "text-accent",
)}
onClick={toggleBrowseMode}
@@ -481,10 +495,10 @@ export function ActionBar({
)}
</button>
)}
{browseMode && suggestions.length > 0 && (
{browseMode && deferredSuggestions.length > 0 && (
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
<ul className="max-h-48 overflow-y-auto py-1">
{suggestions.map((result, i) => (
{deferredSuggestions.map((result, i) => (
<li key={creatureKey(result)}>
<button
type="button"
@@ -498,7 +512,7 @@ export function ActionBar({
onMouseEnter={() => setSuggestionIndex(i)}
>
<span>{result.name}</span>
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground text-xs">
{result.sourceDisplayName}
</span>
</button>
@@ -510,11 +524,12 @@ export function ActionBar({
{!browseMode && hasSuggestions && (
<AddModeSuggestions
nameInput={nameInput}
suggestions={suggestions}
pcMatches={pcMatches}
suggestions={deferredSuggestions}
pcMatches={deferredPcMatches}
suggestionIndex={suggestionIndex}
queued={queued}
onDismiss={clearInput}
onDismiss={dismissSuggestions}
onClear={clearInput}
onClickSuggestion={handleClickSuggestion}
onSetSuggestionIndex={setSuggestionIndex}
onSetQueued={setQueued}
@@ -553,17 +568,16 @@ export function ActionBar({
</div>
)}
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
<Button type="submit" size="sm">
Add
</Button>
<Button type="submit">Add</Button>
)}
{showRollAllInitiative && onRollAllInitiative && (
{showRollAllInitiative && !!onRollAllInitiative && (
<Button
type="button"
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-hover-action"
onClick={onRollAllInitiative}
disabled={rollAllInitiativeDisabled}
title="Roll all initiative"
aria-label="Roll all initiative"
>

View File

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

@@ -9,18 +9,18 @@ interface ColorPaletteProps {
const COLORS = [...VALID_PLAYER_COLORS] as string[];
export function ColorPalette({ value, onChange }: ColorPaletteProps) {
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(color)}
onClick={() => onChange(value === color ? "" : color)}
className={cn(
"h-8 w-8 rounded-full transition-all",
value === color
? "ring-2 ring-foreground ring-offset-2 ring-offset-background scale-110"
? "scale-110 ring-2 ring-foreground ring-offset-2 ring-offset-background"
: "hover:scale-110",
)}
style={{

View File

@@ -50,13 +50,13 @@ function EditableName({
onRename,
onShowStatBlock,
color,
}: {
}: Readonly<{
name: string;
combatantId: CombatantId;
onRename: (id: CombatantId, newName: string) => void;
onShowStatBlock?: () => void;
color?: string;
}) {
}>) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(name);
const inputRef = useRef<HTMLInputElement>(null);
@@ -136,7 +136,6 @@ function EditableName({
}
return (
<>
<button
type="button"
onClick={handleClick}
@@ -144,22 +143,21 @@ function EditableName({
onTouchEnd={cancelLongPress}
onTouchCancel={cancelLongPress}
onTouchMove={cancelLongPress}
className="truncate text-left text-sm text-foreground cursor-text hover:text-hover-neutral transition-colors"
className="cursor-text truncate text-left text-foreground text-sm transition-colors hover:text-hover-neutral"
style={color ? { color } : undefined}
>
{name}
</button>
</>
);
}
function MaxHpDisplay({
maxHp,
onCommit,
}: {
}: Readonly<{
maxHp: number | undefined;
onCommit: (value: number | undefined) => void;
}) {
}>) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(maxHp?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null);
@@ -205,7 +203,7 @@ function MaxHpDisplay({
<button
type="button"
onClick={startEditing}
className="inline-block h-7 min-w-[3ch] text-center text-sm leading-7 tabular-nums text-muted-foreground transition-colors hover:text-hover-neutral"
className="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"}
</button>
@@ -217,12 +215,12 @@ function ClickableHp({
maxHp,
onAdjust,
dimmed,
}: {
}: Readonly<{
currentHp: number | undefined;
maxHp: number | undefined;
onAdjust: (delta: number) => void;
dimmed?: boolean;
}) {
}>) {
const [popoverOpen, setPopoverOpen] = useState(false);
const status = deriveHpStatus(currentHp, maxHp);
@@ -230,9 +228,11 @@ function ClickableHp({
return (
<span
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",
)}
role="status"
aria-label="No HP set"
>
--
</span>
@@ -245,7 +245,7 @@ function ClickableHp({
type="button"
onClick={() => setPopoverOpen(true)}
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 === "unconscious" && "text-red-400",
status === "healthy" && "text-foreground",
@@ -254,7 +254,7 @@ function ClickableHp({
>
{currentHp}
</button>
{popoverOpen && (
{!!popoverOpen && (
<HpAdjustPopover
onAdjust={onAdjust}
onClose={() => setPopoverOpen(false)}
@@ -267,10 +267,10 @@ function ClickableHp({
function AcDisplay({
ac,
onCommit,
}: {
}: Readonly<{
ac: number | undefined;
onCommit: (value: number | undefined) => void;
}) {
}>) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(ac?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null);
@@ -321,13 +321,13 @@ function InitiativeDisplay({
dimmed,
onSetInitiative,
onRollInitiative,
}: {
}: Readonly<{
initiative: number | undefined;
combatantId: CombatantId;
dimmed: boolean;
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
onRollInitiative?: (id: CombatantId) => void;
}) {
}>) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(initiative?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null);
@@ -397,10 +397,10 @@ function InitiativeDisplay({
type="button"
onClick={startEditing}
className={cn(
"h-7 w-full text-center text-sm leading-7 tabular-nums transition-colors",
initiative !== undefined
? "font-medium text-foreground hover:text-hover-neutral"
: "text-muted-foreground hover:text-hover-neutral",
"h-7 w-full text-center text-sm tabular-nums leading-7 transition-colors",
initiative === undefined
? "text-muted-foreground hover:text-hover-neutral"
: "font-medium text-foreground hover:text-hover-neutral",
dimmed && "opacity-50",
)}
>
@@ -491,6 +491,7 @@ export function CombatantRow({
return (
/* biome-ignore lint/a11y/noStaticElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
<div
ref={ref}
role={onShowStatBlock ? "button" : undefined}
@@ -517,7 +518,7 @@ export function CombatantRow({
title="Concentrating"
aria-label="Toggle concentration"
className={cn(
"flex w-full items-center justify-center self-stretch -my-2 -ml-[2px] pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
"-my-2 -ml-[2px] flex w-full items-center justify-center self-stretch pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
concentrationIconClass(combatant.isConcentrating, dimmed),
)}
>
@@ -526,6 +527,7 @@ export function CombatantRow({
{/* Initiative */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */}
<div
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
@@ -542,22 +544,22 @@ export function CombatantRow({
{/* Name + Conditions */}
<div
className={cn(
"relative flex flex-wrap items-center gap-1 min-w-0",
"relative flex min-w-0 flex-wrap items-center gap-1",
dimmed && "opacity-50",
)}
>
{combatant.icon &&
combatant.color &&
{!!combatant.icon &&
!!combatant.color &&
(() => {
const PcIcon = PLAYER_ICON_MAP[combatant.icon as PlayerIcon];
const pcColor =
const iconColor =
PLAYER_COLOR_HEX[
combatant.color as keyof typeof PLAYER_COLOR_HEX
];
return PcIcon ? (
<PcIcon
size={14}
style={{ color: pcColor }}
style={{ color: iconColor }}
className="shrink-0"
/>
) : null;
@@ -574,7 +576,7 @@ export function CombatantRow({
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
onOpenPicker={() => setPickerOpen((prev) => !prev)}
/>
{pickerOpen && (
{!!pickerOpen && (
<ConditionPicker
activeConditions={combatant.conditions}
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
@@ -585,6 +587,7 @@ export function CombatantRow({
{/* AC */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */}
<div
className={cn(dimmed && "opacity-50")}
onClick={(e) => e.stopPropagation()}
@@ -595,6 +598,7 @@ export function CombatantRow({
{/* HP */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */}
<div
className="flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
@@ -609,7 +613,7 @@ export function CombatantRow({
{maxHp !== undefined && (
<span
className={cn(
"text-sm tabular-nums text-muted-foreground",
"text-muted-foreground text-sm tabular-nums",
dimmed && "opacity-50",
)}
>
@@ -626,7 +630,7 @@ export function CombatantRow({
icon={<X size={16} />}
label="Remove combatant"
onConfirm={() => onRemove(id)}
className="h-7 w-7 text-muted-foreground opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus:opacity-100 focus:pointer-events-auto pointer-coarse:opacity-100 pointer-coarse:pointer-events-auto transition-opacity"
className="pointer-events-none pointer-coarse:pointer-events-auto h-7 w-7 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-opacity focus:pointer-events-auto focus:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
/>
</div>
</div>

View File

@@ -61,7 +61,7 @@ export function ConditionPicker({
activeConditions,
onToggle,
onClose,
}: ConditionPickerProps) {
}: Readonly<ConditionPickerProps>) {
const ref = useRef<HTMLDivElement>(null);
const [flipped, setFlipped] = useState(false);
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);

View File

@@ -60,7 +60,7 @@ export function ConditionTags({
conditions,
onRemove,
onOpenPicker,
}: ConditionTagsProps) {
}: Readonly<ConditionTagsProps>) {
return (
<div className="flex flex-wrap items-center gap-0.5">
{conditions?.map((condId) => {
@@ -75,7 +75,7 @@ export function ConditionTags({
type="button"
title={def.label}
aria-label={`Remove ${def.label}`}
className={`inline-flex items-center rounded p-0.5 hover:bg-hover-neutral-bg transition-colors ${colorClass}`}
className={`inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg ${colorClass}`}
onClick={(e) => {
e.stopPropagation();
onRemove(condId);
@@ -89,7 +89,7 @@ export function ConditionTags({
type="button"
title="Add condition"
aria-label="Add condition"
className="inline-flex items-center rounded p-0.5 text-muted-foreground hover:text-hover-neutral hover:bg-hover-neutral-bg transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 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) => {
e.stopPropagation();
onOpenPicker();

View File

@@ -1,6 +1,6 @@
import type { PlayerCharacter } from "@initiative/domain";
import { X } from "lucide-react";
import { type FormEvent, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { ColorPalette } from "./color-palette";
import { IconGrid } from "./icon-grid";
import { Button } from "./ui/button";
@@ -13,8 +13,8 @@ interface CreatePlayerModalProps {
name: string,
ac: number,
maxHp: number,
color: string,
icon: string,
color: string | undefined,
icon: string | undefined,
) => void;
playerCharacter?: PlayerCharacter;
}
@@ -24,7 +24,7 @@ export function CreatePlayerModal({
onClose,
onSave,
playerCharacter,
}: CreatePlayerModalProps) {
}: Readonly<CreatePlayerModalProps>) {
const [name, setName] = useState("");
const [ac, setAc] = useState("10");
const [maxHp, setMaxHp] = useState("10");
@@ -40,14 +40,14 @@ export function CreatePlayerModal({
setName(playerCharacter.name);
setAc(String(playerCharacter.ac));
setMaxHp(String(playerCharacter.maxHp));
setColor(playerCharacter.color);
setIcon(playerCharacter.icon);
setColor(playerCharacter.color ?? "");
setIcon(playerCharacter.icon ?? "");
} else {
setName("");
setAc("10");
setMaxHp("10");
setColor("blue");
setIcon("sword");
setColor("");
setIcon("");
}
setError("");
}
@@ -64,7 +64,7 @@ export function CreatePlayerModal({
if (!open) return null;
const handleSubmit = (e: FormEvent) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
const trimmed = name.trim();
if (trimmed === "") {
@@ -81,37 +81,40 @@ export function CreatePlayerModal({
setError("Max HP must be at least 1");
return;
}
onSave(trimmed, acNum, hpNum, color, icon);
onSave(trimmed, acNum, hpNum, color || undefined, icon || undefined);
onClose();
};
return (
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: backdrop click to close
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onMouseDown={onClose}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: prevent close when clicking modal content */}
<div
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">
<h2 className="font-semibold text-foreground text-lg">
{isEdit ? "Edit Player" : "Create Player"}
</h2>
<button
type="button"
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="text-muted-foreground hover:text-foreground transition-colors"
className="text-muted-foreground"
>
<X size={20} />
</button>
</Button>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<span className="mb-1 block text-sm text-muted-foreground">
<span className="mb-1 block text-muted-foreground text-sm">
Name
</span>
<Input
@@ -125,12 +128,14 @@ export function CreatePlayerModal({
aria-label="Name"
autoFocus
/>
{error && <p className="mt-1 text-sm text-destructive">{error}</p>}
{!!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-sm text-muted-foreground">
<span className="mb-1 block text-muted-foreground text-sm">
AC
</span>
<Input
@@ -144,7 +149,7 @@ export function CreatePlayerModal({
/>
</div>
<div className="flex-1">
<span className="mb-1 block text-sm text-muted-foreground">
<span className="mb-1 block text-muted-foreground text-sm">
Max HP
</span>
<Input
@@ -160,14 +165,14 @@ export function CreatePlayerModal({
</div>
<div>
<span className="mb-2 block text-sm text-muted-foreground">
<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-sm text-muted-foreground">
<span className="mb-2 block text-muted-foreground text-sm">
Icon
</span>
<IconGrid value={icon} onChange={setIcon} />

View File

@@ -6,9 +6,10 @@ import {
useRef,
useState,
} from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
const DIGITS_ONLY_REGEX = /^\d+$/;
interface HpAdjustPopoverProps {
readonly onAdjust: (delta: number) => void;
readonly onClose: () => void;
@@ -103,36 +104,32 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
className="h-7 w-[7ch] text-center text-sm tabular-nums"
onChange={(e) => {
const v = e.target.value;
if (v === "" || /^\d+$/.test(v)) {
if (v === "" || DIGITS_ONLY_REGEX.test(v)) {
setInputValue(v);
}
}}
onKeyDown={handleKeyDown}
/>
<Button
<button
type="button"
variant="ghost"
size="icon"
disabled={!isValid}
className="h-7 w-7 shrink-0 text-red-400 hover:bg-red-950 hover:text-red-300"
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-red-400 transition-colors hover:bg-red-950 hover:text-red-300 disabled:pointer-events-none disabled:opacity-50"
onClick={() => applyDelta(-1)}
title="Apply damage"
aria-label="Apply damage"
>
<Sword size={14} />
</Button>
<Button
</button>
<button
type="button"
variant="ghost"
size="icon"
disabled={!isValid}
className="h-7 w-7 shrink-0 text-emerald-400 hover:bg-emerald-950 hover:text-emerald-300"
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-emerald-400 transition-colors hover:bg-emerald-950 hover:text-emerald-300 disabled:pointer-events-none disabled:opacity-50"
onClick={() => applyDelta(1)}
title="Apply healing"
aria-label="Apply healing"
>
<Heart size={14} />
</Button>
</button>
</div>
</div>
);

View File

@@ -10,7 +10,7 @@ interface IconGridProps {
const ICONS = [...VALID_PLAYER_ICONS] as PlayerIcon[];
export function IconGrid({ value, onChange }: IconGridProps) {
export function IconGrid({ value, onChange }: Readonly<IconGridProps>) {
return (
<div className="flex flex-wrap gap-2">
{ICONS.map((iconId) => {
@@ -19,11 +19,11 @@ export function IconGrid({ value, onChange }: IconGridProps) {
<button
key={iconId}
type="button"
onClick={() => onChange(iconId)}
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 ring-2 ring-primary text-foreground"
? "bg-primary/20 text-foreground ring-2 ring-primary"
: "text-muted-foreground hover:bg-card hover:text-foreground",
)}
aria-label={iconId}

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

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

View File

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

View File

@@ -1,5 +1,5 @@
import { Database, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useOptimistic, useState } from "react";
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
import * as bestiaryCache from "../adapters/bestiary-cache.js";
import { Button } from "./ui/button.js";
@@ -8,8 +8,20 @@ interface SourceManagerProps {
onCacheCleared: () => void;
}
export function SourceManager({ onCacheCleared }: SourceManagerProps) {
export function SourceManager({
onCacheCleared,
}: Readonly<SourceManagerProps>) {
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
const [optimisticSources, applyOptimistic] = useOptimistic(
sources,
(
state,
action: { type: "remove"; sourceCode: string } | { type: "clear" },
) =>
action.type === "clear"
? []
: state.filter((s) => s.sourceCode !== action.sourceCode),
);
const loadSources = useCallback(async () => {
const cached = await bestiaryCache.getCachedSources();
@@ -17,26 +29,28 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
}, []);
useEffect(() => {
loadSources();
void loadSources();
}, [loadSources]);
const handleClearSource = async (sourceCode: string) => {
applyOptimistic({ type: "remove", sourceCode });
await bestiaryCache.clearSource(sourceCode);
await loadSources();
onCacheCleared();
};
const handleClearAll = async () => {
applyOptimistic({ type: "clear" });
await bestiaryCache.clearAll();
await loadSources();
onCacheCleared();
};
if (sources.length === 0) {
if (optimisticSources.length === 0) {
return (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<Database className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No cached sources</p>
<p className="text-muted-foreground text-sm">No cached sources</p>
</div>
);
}
@@ -44,13 +58,12 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-foreground">
<span className="font-semibold text-foreground text-sm">
Cached Sources
</span>
<Button
size="sm"
variant="outline"
className="hover:text-hover-destructive hover:border-hover-destructive"
className="hover:border-hover-destructive hover:text-hover-destructive"
onClick={handleClearAll}
>
<Trash2 className="mr-1 h-3 w-3" />
@@ -58,16 +71,16 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
</Button>
</div>
<ul className="flex flex-col gap-1">
{sources.map((source) => (
{optimisticSources.map((source) => (
<li
key={source.sourceCode}
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
>
<div>
<span className="text-sm text-foreground">
<span className="text-foreground text-sm">
{source.displayName}
</span>
<span className="ml-2 text-xs text-muted-foreground">
<span className="ml-2 text-muted-foreground text-xs">
{source.creatureCount} creatures
</span>
</div>
@@ -75,6 +88,7 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
type="button"
onClick={() => handleClearSource(source.sourceCode)}
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-hover-destructive-bg hover:text-hover-destructive"
aria-label={`Remove ${source.displayName}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>

View File

@@ -7,7 +7,9 @@ import type { BulkImportState } from "../hooks/use-bulk-import.js";
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
import { BulkImportPrompt } from "./bulk-import-prompt.js";
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
import { SourceManager } from "./source-manager.js";
import { StatBlock } from "./stat-block.js";
import { Button } from "./ui/button.js";
interface StatBlockPanelProps {
creatureId: CreatureId | null;
@@ -20,8 +22,8 @@ interface StatBlockPanelProps {
) => Promise<void>;
refreshCache: () => Promise<void>;
panelRole: "browse" | "pinned";
isFolded: boolean;
onToggleFold: () => void;
isCollapsed: boolean;
onToggleCollapse: () => void;
onPin: () => void;
onUnpin: () => void;
showPinButton: boolean;
@@ -31,6 +33,7 @@ interface StatBlockPanelProps {
bulkImportState?: BulkImportState;
onStartBulkImport?: (baseUrl: string) => void;
onBulkImportDone?: () => void;
sourceManagerMode?: boolean;
}
function extractSourceCode(cId: CreatureId): string {
@@ -39,25 +42,25 @@ function extractSourceCode(cId: CreatureId): string {
return cId.slice(0, colonIndex).toUpperCase();
}
function FoldedTab({
function CollapsedTab({
creatureName,
side,
onToggleFold,
}: {
onToggleCollapse,
}: Readonly<{
creatureName: string;
side: "left" | "right";
onToggleFold: () => void;
}) {
onToggleCollapse: () => void;
}>) {
return (
<button
type="button"
onClick={onToggleFold}
onClick={onToggleCollapse}
className={`flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral ${
side === "right" ? "self-start" : "self-end"
}`}
aria-label="Unfold stat block panel"
aria-label="Expand stat block panel"
>
<span className="writing-vertical-rl text-sm font-medium">
<span className="writing-vertical-rl font-medium text-sm">
{creatureName}
</span>
</button>
@@ -67,50 +70,53 @@ function FoldedTab({
function PanelHeader({
panelRole,
showPinButton,
onToggleFold,
onToggleCollapse,
onPin,
onUnpin,
}: {
}: Readonly<{
panelRole: "browse" | "pinned";
showPinButton: boolean;
onToggleFold: () => void;
onToggleCollapse: () => void;
onPin: () => void;
onUnpin: () => void;
}) {
}>) {
return (
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex items-center justify-between border-border border-b px-4 py-2">
<div className="flex items-center gap-1">
{panelRole === "browse" && (
<button
type="button"
onClick={onToggleFold}
className="text-muted-foreground hover:text-hover-neutral"
aria-label="Fold stat block panel"
<Button
variant="ghost"
size="icon-sm"
onClick={onToggleCollapse}
className="text-muted-foreground"
aria-label="Collapse stat block panel"
>
<PanelRightClose className="h-4 w-4" />
</button>
</Button>
)}
</div>
<div className="flex items-center gap-1">
{panelRole === "browse" && showPinButton && (
<button
type="button"
<Button
variant="ghost"
size="icon-sm"
onClick={onPin}
className="text-muted-foreground hover:text-hover-neutral"
className="text-muted-foreground"
aria-label="Pin creature"
>
<Pin className="h-4 w-4" />
</button>
</Button>
)}
{panelRole === "pinned" && (
<button
type="button"
<Button
variant="ghost"
size="icon-sm"
onClick={onUnpin}
className="text-muted-foreground hover:text-hover-neutral"
className="text-muted-foreground"
aria-label="Unpin creature"
>
<PinOff className="h-4 w-4" />
</button>
</Button>
)}
</div>
</div>
@@ -118,48 +124,48 @@ function PanelHeader({
}
function DesktopPanel({
isFolded,
isCollapsed,
side,
creatureName,
panelRole,
showPinButton,
onToggleFold,
onToggleCollapse,
onPin,
onUnpin,
children,
}: {
isFolded: boolean;
}: Readonly<{
isCollapsed: boolean;
side: "left" | "right";
creatureName: string;
panelRole: "browse" | "pinned";
showPinButton: boolean;
onToggleFold: () => void;
onToggleCollapse: () => void;
onPin: () => void;
onUnpin: () => void;
children: ReactNode;
}) {
}>) {
const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l";
const foldedTranslate =
const collapsedTranslate =
side === "right"
? "translate-x-[calc(100%-40px)]"
: "translate-x-[calc(-100%+40px)]";
return (
<div
className={`fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel ${sideClasses} ${isFolded ? foldedTranslate : "translate-x-0"}`}
className={`fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel ${sideClasses} ${isCollapsed ? collapsedTranslate : "translate-x-0"}`}
>
{isFolded ? (
<FoldedTab
{isCollapsed ? (
<CollapsedTab
creatureName={creatureName}
side={side}
onToggleFold={onToggleFold}
onToggleCollapse={onToggleCollapse}
/>
) : (
<>
<PanelHeader
panelRole={panelRole}
showPinButton={showPinButton}
onToggleFold={onToggleFold}
onToggleCollapse={onToggleCollapse}
onPin={onPin}
onUnpin={onUnpin}
/>
@@ -173,36 +179,37 @@ function DesktopPanel({
function MobileDrawer({
onDismiss,
children,
}: {
}: Readonly<{
onDismiss: () => void;
children: ReactNode;
}) {
}>) {
const { offsetX, isSwiping, handlers } = useSwipeToDismiss(onDismiss);
return (
<div className="fixed inset-0 z-50">
<button
type="button"
className="absolute inset-0 bg-black/50 animate-in fade-in"
className="fade-in absolute inset-0 animate-in bg-black/50"
onClick={onDismiss}
aria-label="Close stat block"
/>
<div
className={`absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-l border-border bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`}
className={`absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-border border-l bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`}
style={
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
}
{...handlers}
>
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<button
type="button"
<div className="flex items-center justify-between border-border border-b px-4 py-2">
<Button
variant="ghost"
size="icon-sm"
onClick={onDismiss}
className="text-muted-foreground hover:text-hover-neutral"
aria-label="Fold stat block panel"
className="text-muted-foreground"
aria-label="Collapse stat block panel"
>
<PanelRightClose className="h-4 w-4" />
</button>
</Button>
</div>
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
{children}
@@ -220,8 +227,8 @@ export function StatBlockPanel({
uploadAndCacheSource,
refreshCache,
panelRole,
isFolded,
onToggleFold,
isCollapsed,
onToggleCollapse,
onPin,
onUnpin,
showPinButton,
@@ -231,15 +238,16 @@ export function StatBlockPanel({
bulkImportState,
onStartBulkImport,
onBulkImportDone,
}: StatBlockPanelProps) {
sourceManagerMode,
}: Readonly<StatBlockPanelProps>) {
const [isDesktop, setIsDesktop] = useState(
() => window.matchMedia("(min-width: 1024px)").matches,
() => globalThis.matchMedia("(min-width: 1024px)").matches,
);
const [needsFetch, setNeedsFetch] = useState(false);
const [checkingCache, setCheckingCache] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(min-width: 1024px)");
const mq = globalThis.matchMedia("(min-width: 1024px)");
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
@@ -258,13 +266,13 @@ export function StatBlockPanel({
}
setCheckingCache(true);
isSourceCached(sourceCode).then((cached) => {
void isSourceCached(sourceCode).then((cached) => {
setNeedsFetch(!cached);
setCheckingCache(false);
});
}, [creatureId, creature, isSourceCached]);
if (!creatureId && !bulkImportMode) return null;
if (!creatureId && !bulkImportMode && !sourceManagerMode) return null;
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
@@ -274,6 +282,10 @@ export function StatBlockPanel({
};
const renderContent = () => {
if (sourceManagerMode) {
return <SourceManager onCacheCleared={refreshCache} />;
}
if (
bulkImportMode &&
bulkImportState &&
@@ -291,7 +303,7 @@ export function StatBlockPanel({
if (checkingCache) {
return (
<div className="p-4 text-sm text-muted-foreground">Loading...</div>
<div className="p-4 text-muted-foreground text-sm">Loading...</div>
);
}
@@ -312,24 +324,26 @@ export function StatBlockPanel({
}
return (
<div className="p-4 text-sm text-muted-foreground">
<div className="p-4 text-muted-foreground text-sm">
No stat block available
</div>
);
};
const creatureName =
creature?.name ?? (bulkImportMode ? "Bulk Import" : "Creature");
let fallbackName = "Creature";
if (sourceManagerMode) fallbackName = "Sources";
else if (bulkImportMode) fallbackName = "Import All Sources";
const creatureName = creature?.name ?? fallbackName;
if (isDesktop) {
return (
<DesktopPanel
isFolded={isFolded}
isCollapsed={isCollapsed}
side={side}
creatureName={creatureName}
panelRole={panelRole}
showPinButton={showPinButton}
onToggleFold={onToggleFold}
onToggleCollapse={onToggleCollapse}
onPin={onPin}
onUnpin={onUnpin}
>

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ export function TurnNavigation({
onAdvanceTurn,
onRetreatTurn,
onClearEncounter,
}: TurnNavigationProps) {
}: Readonly<TurnNavigationProps>) {
const hasCombatants = encounter.combatants.length > 0;
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
const activeCombatant = encounter.combatants[encounter.activeIndex];
@@ -23,6 +23,7 @@ export function TurnNavigation({
return (
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
<Button
variant="outline"
size="icon"
onClick={onRetreatTurn}
disabled={!hasCombatants || isAtStart}
@@ -32,8 +33,8 @@ export function TurnNavigation({
<StepBack className="h-5 w-5" />
</Button>
<div className="min-w-0 flex-1 flex items-center justify-center gap-2 text-sm">
<span className="rounded-full bg-muted text-foreground text-sm px-2 py-0.5 font-semibold shrink-0">
<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}
</span>
{activeCombatant ? (
@@ -52,6 +53,7 @@ export function TurnNavigation({
className="text-muted-foreground"
/>
<Button
variant="outline"
size="icon"
onClick={onAdvanceTurn}
disabled={!hasCombatants}

View File

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

View File

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

View File

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

View File

@@ -48,13 +48,13 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
>
<EllipsisVertical className="h-5 w-5" />
</Button>
{open && (
<div className="absolute bottom-full right-0 z-50 mb-1 min-w-48 rounded-md border border-border bg-card py-1 shadow-lg">
{!!open && (
<div className="absolute right-0 bottom-full z-50 mb-1 min-w-48 rounded-md border border-border bg-card py-1 shadow-lg">
{items.map((item) => (
<button
key={item.label}
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-muted/20 disabled:pointer-events-none disabled:opacity-50"
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();

View File

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

View File

@@ -48,7 +48,7 @@ export function useBulkImport(): BulkImportHook {
countersRef.current = { completed: 0, failed: 0 };
setState({ status: "loading", total, completed: 0, failed: 0 });
(async () => {
void (async () => {
const cacheChecks = await Promise.all(
allCodes.map(async (code) => ({
code,
@@ -75,6 +75,7 @@ export function useBulkImport(): BulkImportHook {
for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
const batch = uncached.slice(i, i + BATCH_SIZE);
// biome-ignore lint/performance/noAwaitInLoops: sequential batching is intentional to avoid overwhelming the server with too many concurrent requests
await Promise.allSettled(
batch.map(async ({ code }) => {
const url = getDefaultFetchUrl(code, baseUrl);

View File

@@ -33,6 +33,8 @@ import {
saveEncounter,
} from "../persistence/encounter-storage.js";
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
const EMPTY_ENCOUNTER: Encounter = {
combatants: [],
activeIndex: 0,
@@ -48,7 +50,7 @@ function initializeEncounter(): Encounter {
function deriveNextId(encounter: Encounter): number {
let max = 0;
for (const c of encounter.combatants) {
const match = /^c-(\d+)$/.exec(c.id);
const match = COMBATANT_ID_REGEX.exec(c.id);
if (match) {
const n = Number.parseInt(match[1], 10);
if (n > max) max = n;
@@ -301,8 +303,8 @@ export function useEncounter() {
// Derive creatureId from source + name
const slug = entry.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/(^-|-$)/g, "");
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
// Set creatureId on the combatant (use store.save to keep ref in sync for batch calls)
@@ -316,7 +318,7 @@ export function useEncounter() {
setEvents((prev) => [...prev, ...addResult]);
},
[makeStore, editCombatant],
[makeStore],
);
const addFromPlayerCharacter = useCallback(
@@ -368,12 +370,23 @@ export function useEncounter() {
setEvents((prev) => [...prev, ...addResult]);
},
[makeStore, editCombatant],
[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 {
encounter,
events,
isEmpty,
hasCreatureCombatants,
canRollAllInitiative,
advanceTurn,
retreatTurn,
addCombatant,

View File

@@ -26,8 +26,8 @@ interface EditFields {
readonly name?: string;
readonly ac?: number;
readonly maxHp?: number;
readonly color?: string;
readonly icon?: string;
readonly color?: string | null;
readonly icon?: string | null;
}
export function usePlayerCharacters() {
@@ -51,7 +51,13 @@ export function usePlayerCharacters() {
}, []);
const createCharacter = useCallback(
(name: string, ac: number, maxHp: number, color: string, icon: string) => {
(
name: string,
ac: number,
maxHp: number,
color: string | undefined,
icon: string | undefined,
) => {
const id = generatePcId();
const result = createPlayerCharacterUseCase(
makeStore(),

View File

@@ -0,0 +1,101 @@
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;
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 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,
showBulkImport,
showSourceManager,
dismissPanel,
toggleCollapse,
togglePin,
unpin,
};
}

View File

@@ -16,7 +16,7 @@
--color-hover-neutral: var(--color-primary);
--color-hover-action: var(--color-primary);
--color-hover-destructive: var(--color-destructive);
--color-hover-neutral-bg: var(--color-card);
--color-hover-neutral-bg: oklch(0.623 0.214 259 / 0.15);
--color-hover-action-bg: var(--color-muted);
--color-hover-destructive-bg: transparent;
--radius-sm: 0.25rem;

View File

@@ -15,6 +15,13 @@ export function savePlayerCharacters(characters: PlayerCharacter[]): void {
}
}
function isValidOptionalMember(
value: unknown,
valid: ReadonlySet<string>,
): boolean {
return value === undefined || (typeof value === "string" && valid.has(value));
}
function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
return null;
@@ -35,10 +42,8 @@ function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
entry.maxHp < 1
)
return null;
if (typeof entry.color !== "string" || !VALID_PLAYER_COLORS.has(entry.color))
return null;
if (typeof entry.icon !== "string" || !VALID_PLAYER_ICONS.has(entry.icon))
return null;
if (!isValidOptionalMember(entry.color, VALID_PLAYER_COLORS)) return null;
if (!isValidOptionalMember(entry.icon, VALID_PLAYER_ICONS)) return null;
return {
id: playerCharacterId(entry.id),

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

View File

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

View File

@@ -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,
"packageManager": "pnpm@10.6.0",
"pnpm": {
"overrides": {
"undici": ">=7.24.0"
}
},
"devDependencies": {
"@biomejs/biome": "2.0.0",
"@biomejs/biome": "2.4.7",
"@vitest/coverage-v8": "^3.2.4",
"jscpd": "^4.0.8",
"knip": "^5.85.0",
"lefthook": "^1.11.0",
"oxlint": "^1.55.0",
"oxlint-tsgolint": "^0.16.0",
"typescript": "^5.8.0",
"vitest": "^3.0.0"
},
@@ -21,6 +28,7 @@
"test:watch": "vitest",
"knip": "knip",
"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": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && 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,195 @@
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("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,155 @@
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("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

@@ -13,8 +13,8 @@ export function createPlayerCharacterUseCase(
name: string,
ac: number,
maxHp: number,
color: string,
icon: string,
color: string | undefined,
icon: string | undefined,
): DomainEvent[] | DomainError {
const characters = store.getAll();
const result = createPlayerCharacter(

View File

@@ -11,8 +11,8 @@ interface EditFields {
readonly name?: string;
readonly ac?: number;
readonly maxHp?: number;
readonly color?: string;
readonly icon?: string;
readonly color?: string | null;
readonly icon?: string | null;
}
export function editPlayerCharacterUseCase(

View File

@@ -13,7 +13,10 @@ export type {
} from "./ports.js";
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
export { rollAllInitiativeUseCase } from "./roll-all-initiative-use-case.js";
export {
type RollAllResult,
rollAllInitiativeUseCase,
} from "./roll-all-initiative-use-case.js";
export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
export { setAcUseCase } from "./set-ac-use-case.js";
export { setHpUseCase } from "./set-hp-use-case.js";

View File

@@ -10,20 +10,29 @@ import {
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
export interface RollAllResult {
events: DomainEvent[];
skippedNoSource: number;
}
export function rollAllInitiativeUseCase(
store: EncounterStore,
rollDice: () => number,
getCreature: (id: CreatureId) => Creature | undefined,
): DomainEvent[] | DomainError {
): RollAllResult | DomainError {
let encounter = store.get();
const allEvents: DomainEvent[] = [];
let skippedNoSource = 0;
for (const combatant of encounter.combatants) {
if (!combatant.creatureId) continue;
if (combatant.initiative !== undefined) continue;
const creature = getCreature(combatant.creatureId);
if (!creature) continue;
if (!creature) {
skippedNoSource++;
continue;
}
const { modifier } = calculateInitiative({
dexScore: creature.abilities.dex,
@@ -47,5 +56,5 @@ export function rollAllInitiativeUseCase(
}
store.save(encounter);
return allEvents;
return { events: allEvents, skippedNoSource };
}

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { addCombatant } from "../add-combatant.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers ---
@@ -112,20 +113,14 @@ describe("addCombatant", () => {
const e = enc([A, B]);
const result = addCombatant(e, combatantId("x"), "");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
expectDomainError(result, "invalid-name");
});
it("scenario 6: whitespace-only name returns error", () => {
const e = enc([A, B]);
const result = addCombatant(e, combatantId("x"), " ");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
expectDomainError(result, "invalid-name");
});
});
@@ -146,12 +141,10 @@ describe("addCombatant", () => {
for (const e of scenarios) {
const result = successResult(e, "new", "New");
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).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", () => {
const e = enc([A, B]);
const { encounter } = successResult(e, "C", "C");
expect(encounter.combatants[encounter.combatants.length - 1]).toEqual({
expect(encounter.combatants.at(-1)).toEqual({
id: combatantId("C"),
name: "C",
});

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ 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");
@@ -80,10 +81,7 @@ describe("createPlayerCharacter", () => {
it("rejects empty name", () => {
const result = createPlayerCharacter([], id, "", 10, 50, "blue", "sword");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
expectDomainError(result, "invalid-name");
});
it("rejects whitespace-only name", () => {
@@ -96,10 +94,7 @@ describe("createPlayerCharacter", () => {
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
expectDomainError(result, "invalid-name");
});
it("rejects negative AC", () => {
@@ -112,10 +107,7 @@ describe("createPlayerCharacter", () => {
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
expectDomainError(result, "invalid-ac");
});
it("rejects non-integer AC", () => {
@@ -128,10 +120,7 @@ describe("createPlayerCharacter", () => {
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
expectDomainError(result, "invalid-ac");
});
it("allows AC of 0", () => {
@@ -149,10 +138,7 @@ describe("createPlayerCharacter", () => {
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
expectDomainError(result, "invalid-max-hp");
});
it("rejects negative maxHp", () => {
@@ -165,10 +151,7 @@ describe("createPlayerCharacter", () => {
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
expectDomainError(result, "invalid-max-hp");
});
it("rejects non-integer maxHp", () => {
@@ -181,10 +164,7 @@ describe("createPlayerCharacter", () => {
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
expectDomainError(result, "invalid-max-hp");
});
it("rejects invalid color", () => {
@@ -197,10 +177,7 @@ describe("createPlayerCharacter", () => {
"neon",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-color");
}
expectDomainError(result, "invalid-color");
});
it("rejects invalid icon", () => {
@@ -213,10 +190,50 @@ describe("createPlayerCharacter", () => {
"blue",
"banana",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-icon");
}
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", () => {

View File

@@ -3,6 +3,7 @@ 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");
@@ -28,10 +29,7 @@ describe("deletePlayerCharacter", () => {
it("returns error for not-found id", () => {
const result = deletePlayerCharacter([makePC()], id2);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("player-character-not-found");
}
expectDomainError(result, "player-character-not-found");
});
it("emits PlayerCharacterDeleted event", () => {

View File

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

View File

@@ -3,6 +3,7 @@ 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");
@@ -42,50 +43,32 @@ describe("editPlayerCharacter", () => {
playerCharacterId("pc-999"),
{ name: "Nope" },
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("player-character-not-found");
}
expectDomainError(result, "player-character-not-found");
});
it("rejects empty name", () => {
const result = editPlayerCharacter([makePC()], id, { name: "" });
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
expectDomainError(result, "invalid-name");
});
it("rejects invalid AC", () => {
const result = editPlayerCharacter([makePC()], id, { ac: -1 });
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
expectDomainError(result, "invalid-ac");
});
it("rejects invalid maxHp", () => {
const result = editPlayerCharacter([makePC()], id, { maxHp: 0 });
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
expectDomainError(result, "invalid-max-hp");
});
it("rejects invalid color", () => {
const result = editPlayerCharacter([makePC()], id, { color: "neon" });
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-color");
}
expectDomainError(result, "invalid-color");
});
it("rejects invalid icon", () => {
const result = editPlayerCharacter([makePC()], id, { icon: "banana" });
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-icon");
}
expectDomainError(result, "invalid-icon");
});
it("returns error when no fields changed", () => {
@@ -94,10 +77,7 @@ describe("editPlayerCharacter", () => {
name: pc.name,
ac: pc.ac,
});
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("no-changes");
}
expectDomainError(result, "no-changes");
});
it("emits exactly one event on success", () => {
@@ -106,6 +86,22 @@ describe("editPlayerCharacter", () => {
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);

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { rollInitiative } from "../roll-initiative.js";
import { isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
describe("rollInitiative", () => {
describe("valid rolls", () => {
@@ -32,18 +33,12 @@ describe("rollInitiative", () => {
describe("invalid dice rolls", () => {
it("rejects 0", () => {
const result = rollInitiative(0, 5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-dice-roll");
}
expectDomainError(result, "invalid-dice-roll");
});
it("rejects 21", () => {
const result = rollInitiative(21, 5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-dice-roll");
}
expectDomainError(result, "invalid-dice-roll");
});
it("rejects non-integer (3.5)", () => {

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { setInitiative } from "../set-initiative.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers ---
@@ -73,10 +74,7 @@ describe("setInitiative", () => {
const e = enc([A, B], 0);
const result = setInitiative(e, combatantId("A"), 3.5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-initiative");
}
expectDomainError(result, "invalid-initiative");
});
it("AS-3b: reject NaN", () => {
@@ -109,10 +107,7 @@ describe("setInitiative", () => {
const e = enc([A, B], 0);
const result = setInitiative(e, combatantId("nonexistent"), 10);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
expectDomainError(result, "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 type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant(name: string, isConcentrating?: boolean): Combatant {
return isConcentrating
@@ -46,10 +47,7 @@ describe("toggleConcentration", () => {
const e = enc([makeCombatant("A")]);
const result = toggleConcentration(e, combatantId("missing"));
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
expectDomainError(result, "combatant-not-found");
});
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 type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant(
name: string,
@@ -77,20 +78,14 @@ describe("toggleCondition", () => {
"flying" as ConditionId,
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("unknown-condition");
}
expectDomainError(result, "unknown-condition");
});
it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A")]);
const result = toggleCondition(e, combatantId("missing"), "blinded");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
expectDomainError(result, "combatant-not-found");
});
it("does not mutate input encounter", () => {

View File

@@ -1,6 +1,5 @@
import type { DomainEvent } from "./events.js";
import type { DomainError, Encounter } from "./types.js";
import { isDomainError } from "./types.js";
interface AdvanceTurnSuccess {
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) {
exactMatches.push(i);
} 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) {
const num = Number.parseInt(match[1], 10);
if (num > maxNumber) maxNumber = num;
@@ -50,5 +53,5 @@ export function resolveCreatureName(
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return s.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
}

View File

@@ -20,8 +20,8 @@ export function createPlayerCharacter(
name: string,
ac: number,
maxHp: number,
color: string,
icon: string,
color: string | undefined,
icon: string | undefined,
): CreatePlayerCharacterSuccess | DomainError {
const trimmed = name.trim();
@@ -49,7 +49,7 @@ export function createPlayerCharacter(
};
}
if (!VALID_PLAYER_COLORS.has(color)) {
if (color !== undefined && !VALID_PLAYER_COLORS.has(color)) {
return {
kind: "domain-error",
code: "invalid-color",
@@ -57,7 +57,7 @@ export function createPlayerCharacter(
};
}
if (!VALID_PLAYER_ICONS.has(icon)) {
if (icon !== undefined && !VALID_PLAYER_ICONS.has(icon)) {
return {
kind: "domain-error",
code: "invalid-icon",

View File

@@ -18,12 +18,12 @@ interface EditFields {
readonly name?: string;
readonly ac?: number;
readonly maxHp?: number;
readonly color?: string;
readonly icon?: string;
readonly color?: string | null;
readonly icon?: string | null;
}
function validateFields(fields: EditFields): DomainError | null {
if (fields.name !== undefined && fields.name.trim() === "") {
if (fields.name?.trim() === "") {
return {
kind: "domain-error",
code: "invalid-name",
@@ -50,14 +50,22 @@ function validateFields(fields: EditFields): DomainError | null {
message: "Max HP must be a positive integer",
};
}
if (fields.color !== undefined && !VALID_PLAYER_COLORS.has(fields.color)) {
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 && !VALID_PLAYER_ICONS.has(fields.icon)) {
if (
fields.icon !== undefined &&
fields.icon !== null &&
!VALID_PLAYER_ICONS.has(fields.icon)
) {
return {
kind: "domain-error",
code: "invalid-icon",
@@ -73,17 +81,17 @@ function applyFields(
): PlayerCharacter {
return {
id: existing.id,
name: fields.name !== undefined ? fields.name.trim() : existing.name,
ac: fields.ac !== undefined ? fields.ac : existing.ac,
maxHp: fields.maxHp !== undefined ? fields.maxHp : existing.maxHp,
name: fields.name?.trim() ?? existing.name,
ac: fields.ac ?? existing.ac,
maxHp: fields.maxHp ?? existing.maxHp,
color:
fields.color !== undefined
? (fields.color as PlayerCharacter["color"])
: existing.color,
fields.color === undefined
? existing.color
: ((fields.color as PlayerCharacter["color"]) ?? undefined),
icon:
fields.icon !== undefined
? (fields.icon as PlayerCharacter["icon"])
: existing.icon,
fields.icon === undefined
? existing.icon
: ((fields.icon as PlayerCharacter["icon"]) ?? undefined),
};
}

View File

@@ -72,8 +72,8 @@ export interface PlayerCharacter {
readonly name: string;
readonly ac: number;
readonly maxHp: number;
readonly color: PlayerColor;
readonly icon: PlayerIcon;
readonly color?: PlayerColor;
readonly icon?: PlayerIcon;
}
export interface PlayerCharacterList {

View File

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

View File

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

View File

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

369
pnpm-lock.yaml generated
View File

@@ -4,13 +4,16 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
undici: '>=7.24.0'
importers:
.:
devDependencies:
'@biomejs/biome':
specifier: 2.0.0
version: 2.0.0
specifier: 2.4.7
version: 2.4.7
'@vitest/coverage-v8':
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/node@25.3.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.31.1))
@@ -23,6 +26,12 @@ importers:
lefthook:
specifier: ^1.11.0
version: 1.13.6
oxlint:
specifier: ^1.55.0
version: 1.55.0(oxlint-tsgolint@0.16.0)
oxlint-tsgolint:
specifier: ^0.16.0
version: 0.16.0
typescript:
specifier: ^5.8.0
version: 5.9.3
@@ -69,6 +78,9 @@ importers:
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@testing-library/user-event':
specifier: ^14.6.1
version: 14.6.1(@testing-library/dom@10.4.1)
'@types/react':
specifier: ^19.0.0
version: 19.2.14
@@ -209,55 +221,55 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@biomejs/biome@2.0.0':
resolution: {integrity: sha512-BlUoXEOI/UQTDEj/pVfnkMo8SrZw3oOWBDrXYFT43V7HTkIUDkBRY53IC5Jx1QkZbaB+0ai1wJIfYwp9+qaJTQ==}
'@biomejs/biome@2.4.7':
resolution: {integrity: sha512-vXrgcmNGZ4lpdwZSpMf1hWw1aWS6B+SyeSYKTLrNsiUsAdSRN0J4d/7mF3ogJFbIwFFSOL3wT92Zzxia/d5/ng==}
engines: {node: '>=14.21.3'}
hasBin: true
'@biomejs/cli-darwin-arm64@2.0.0':
resolution: {integrity: sha512-QvqWYtFFhhxdf8jMAdJzXW+Frc7X8XsnHQLY+TBM1fnT1TfeV/v9vsFI5L2J7GH6qN1+QEEJ19jHibCY2Ypplw==}
'@biomejs/cli-darwin-arm64@2.4.7':
resolution: {integrity: sha512-Oo0cF5mHzmvDmTXw8XSjhCia8K6YrZnk7aCS54+/HxyMdZMruMO3nfpDsrlar/EQWe41r1qrwKiCa2QDYHDzWA==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [darwin]
'@biomejs/cli-darwin-x64@2.0.0':
resolution: {integrity: sha512-5JFhls1EfmuIH4QGFPlNpxJQFC6ic3X1ltcoLN+eSRRIPr6H/lUS1ttuD0Fj7rPgPhZqopK/jfH8UVj/1hIsQw==}
'@biomejs/cli-darwin-x64@2.4.7':
resolution: {integrity: sha512-I+cOG3sd/7HdFtvDSnF9QQPrWguUH7zrkIMMykM3PtfWU9soTcS2yRb9Myq6MHmzbeCT08D1UmY+BaiMl5CcoQ==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [darwin]
'@biomejs/cli-linux-arm64-musl@2.0.0':
resolution: {integrity: sha512-Bxsz8ki8+b3PytMnS5SgrGV+mbAWwIxI3ydChb/d1rURlJTMdxTTq5LTebUnlsUWAX6OvJuFeiVq9Gjn1YbCyA==}
'@biomejs/cli-linux-arm64-musl@2.4.7':
resolution: {integrity: sha512-I2NvM9KPb09jWml93O2/5WMfNR7Lee5Latag1JThDRMURVhPX74p9UDnyTw3Ae6cE1DgXfw7sqQgX7rkvpc0vw==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
'@biomejs/cli-linux-arm64@2.0.0':
resolution: {integrity: sha512-BAH4QVi06TzAbVchXdJPsL0Z/P87jOfes15rI+p3EX9/EGTfIjaQ9lBVlHunxcmoptaA5y1Hdb9UYojIhmnjIw==}
'@biomejs/cli-linux-arm64@2.4.7':
resolution: {integrity: sha512-om6FugwmibzfP/6ALj5WRDVSND4H2G9X0nkI1HZpp2ySf9lW2j0X68oQSaHEnls6666oy4KDsc5RFjT4m0kV0w==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
'@biomejs/cli-linux-x64-musl@2.0.0':
resolution: {integrity: sha512-tiQ0ABxMJb9I6GlfNp0ulrTiQSFacJRJO8245FFwE3ty3bfsfxlU/miblzDIi+qNrgGsLq5wIZcVYGp4c+HXZA==}
'@biomejs/cli-linux-x64-musl@2.4.7':
resolution: {integrity: sha512-00kx4YrBMU8374zd2wHuRV5wseh0rom5HqRND+vDldJPrWwQw+mzd/d8byI9hPx926CG+vWzq6AeiT7Yi5y59g==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
'@biomejs/cli-linux-x64@2.0.0':
resolution: {integrity: sha512-09PcOGYTtkopWRm6mZ/B6Mr6UHdkniUgIG/jLBv+2J8Z61ezRE+xQmpi3yNgUrFIAU4lPA9atg7mhvE/5Bo7Wg==}
'@biomejs/cli-linux-x64@2.4.7':
resolution: {integrity: sha512-bV8/uo2Tj+gumnk4sUdkerWyCPRabaZdv88IpbmDWARQQoA/Q0YaqPz1a+LSEDIL7OfrnPi9Hq1Llz4ZIGyIQQ==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
'@biomejs/cli-win32-arm64@2.0.0':
resolution: {integrity: sha512-vrTtuGu91xNTEQ5ZcMJBZuDlqr32DWU1r14UfePIGndF//s2WUAmer4FmgoPgruo76rprk37e8S2A2c0psXdxw==}
'@biomejs/cli-win32-arm64@2.4.7':
resolution: {integrity: sha512-hOUHBMlFCvDhu3WCq6vaBoG0dp0LkWxSEnEEsxxXvOa9TfT6ZBnbh72A/xBM7CBYB7WgwqboetzFEVDnMxelyw==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [win32]
'@biomejs/cli-win32-x64@2.0.0':
resolution: {integrity: sha512-2USVQ0hklNsph/KIR72ZdeptyXNnQ3JdzPn3NbjI4Sna34CnxeiYAaZcZzXPDl5PYNFBivV4xmvT3Z3rTmyDBg==}
'@biomejs/cli-win32-x64@2.4.7':
resolution: {integrity: sha512-qEpGjSkPC3qX4ycbMUthXvi9CkRq7kZpkqMY1OyhmYlYLnANnooDQ7hDerM8+0NJ+DZKVnsIc07h30XOpt7LtQ==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [win32]
@@ -629,6 +641,150 @@ packages:
cpu: [x64]
os: [win32]
'@oxlint-tsgolint/darwin-arm64@0.16.0':
resolution: {integrity: sha512-WQt5lGwRPJBw7q2KNR0mSPDAaMmZmVvDlEEti96xLO7ONhyomQc6fBZxxwZ4qTFedjJnrHX94sFelZ4OKzS7UQ==}
cpu: [arm64]
os: [darwin]
'@oxlint-tsgolint/darwin-x64@0.16.0':
resolution: {integrity: sha512-VJo29XOzdkalvCTiE2v6FU3qZlgHaM8x8hUEVJGPU2i5W+FlocPpmn00+Ld2n7Q0pqIjyD5EyvZ5UmoIEJMfqg==}
cpu: [x64]
os: [darwin]
'@oxlint-tsgolint/linux-arm64@0.16.0':
resolution: {integrity: sha512-MPfqRt1+XRHv9oHomcBMQ3KpTE+CSkZz14wUxDQoqTNdUlV0HWdzwIE9q65I3D9YyxEnqpM7j4qtDQ3apqVvbQ==}
cpu: [arm64]
os: [linux]
'@oxlint-tsgolint/linux-x64@0.16.0':
resolution: {integrity: sha512-XQSwVUsnwLokMhe1TD6IjgvW5WMTPzOGGkdFDtXWQmlN2YeTw94s/NN0KgDrn2agM1WIgAenEkvnm0u7NgwEyw==}
cpu: [x64]
os: [linux]
'@oxlint-tsgolint/win32-arm64@0.16.0':
resolution: {integrity: sha512-EWdlspQiiFGsP2AiCYdhg5dTYyAlj6y1nRyNI2dQWq4Q/LITFHiSRVPe+7m7K7lcsZCEz2icN/bCeSkZaORqIg==}
cpu: [arm64]
os: [win32]
'@oxlint-tsgolint/win32-x64@0.16.0':
resolution: {integrity: sha512-1ufk8cgktXJuJZHKF63zCHAkaLMwZrEXnZ89H2y6NO85PtOXqu4zbdNl0VBpPP3fCUuUBu9RvNqMFiv0VsbXWA==}
cpu: [x64]
os: [win32]
'@oxlint/binding-android-arm-eabi@1.55.0':
resolution: {integrity: sha512-NhvgAhncTSOhRahQSCnkK/4YIGPjTmhPurQQ2dwt2IvwCMTvZRW5vF2K10UBOxFve4GZDMw6LtXZdC2qeuYIVQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxlint/binding-android-arm64@1.55.0':
resolution: {integrity: sha512-P9iWRh+Ugqhg+D7rkc7boHX8o3H2h7YPcZHQIgvVBgnua5tk4LR2L+IBlreZs58/95cd2x3/004p5VsQM9z4SA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxlint/binding-darwin-arm64@1.55.0':
resolution: {integrity: sha512-esakkJIt7WFAhT30P/Qzn96ehFpzdZ1mNuzpOb8SCW7lI4oB8VsyQnkSHREM671jfpuBb/o2ppzBCx5l0jpgMA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxlint/binding-darwin-x64@1.55.0':
resolution: {integrity: sha512-xDMFRCCAEK9fOH6As2z8ELsC+VDGSFRHwIKVSilw+xhgLwTDFu37rtmRbmUlx8rRGS6cWKQPTc47AVxAZEVVPQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxlint/binding-freebsd-x64@1.55.0':
resolution: {integrity: sha512-mYZqnwUD7ALCRxGenyLd1uuG+rHCL+OTT6S8FcAbVm/ZT2AZMGjvibp3F6k1SKOb2aeqFATmwRykrE41Q0GWVw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxlint/binding-linux-arm-gnueabihf@1.55.0':
resolution: {integrity: sha512-LcX6RYcF9vL9ESGwJW3yyIZ/d/ouzdOKXxCdey1q0XJOW1asrHsIg5MmyKdEBR4plQx+shvYeQne7AzW5f3T1w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm-musleabihf@1.55.0':
resolution: {integrity: sha512-C+8GS1rPtK+dI7mJFkqoRBkDuqbrNihnyYQsJPS9ez+8zF9JzfvU19lawqt4l/Y23o5uQswE/DORa8aiXUih3w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm64-gnu@1.55.0':
resolution: {integrity: sha512-ErLE4XbmcCopA4/CIDiH6J1IAaDOMnf/KSx/aFObs4/OjAAM3sFKWGZ57pNOMxhhyBdcmcXwYymph9GwcpcqgQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
'@oxlint/binding-linux-arm64-musl@1.55.0':
resolution: {integrity: sha512-/kp65avi6zZfqEng56TTuhiy3P/3pgklKIdf38yvYeJ9/PgEeRA2A2AqKAKbZBNAqUzrzHhz9jF6j/PZvhJzTQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
'@oxlint/binding-linux-ppc64-gnu@1.55.0':
resolution: {integrity: sha512-A6pTdXwcEEwL/nmz0eUJ6WxmxcoIS+97GbH96gikAyre3s5deC7sts38ZVVowjS2QQFuSWkpA4ZmQC0jZSNvJQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
'@oxlint/binding-linux-riscv64-gnu@1.55.0':
resolution: {integrity: sha512-clj0lnIN+V52G9tdtZl0LbdTSurnZ1NZj92Je5X4lC7gP5jiCSW+Y/oiDiSauBAD4wrHt2S7nN3pA0zfKYK/6Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
'@oxlint/binding-linux-riscv64-musl@1.55.0':
resolution: {integrity: sha512-NNu08pllN5x/O94/sgR3DA8lbrGBnTHsINZZR0hcav1sj79ksTiKKm1mRzvZvacwQ0hUnGinFo+JO75ok2PxYg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
'@oxlint/binding-linux-s390x-gnu@1.55.0':
resolution: {integrity: sha512-BvfQz3PRlWZRoEZ17dZCqgQsMRdpzGZomJkVATwCIGhHVVeHJMQdmdXPSjcT1DCNUrOjXnVyj1RGDj5+/Je2+Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
'@oxlint/binding-linux-x64-gnu@1.55.0':
resolution: {integrity: sha512-ngSOoFCSBMKVQd24H8zkbcBNc7EHhjnF1sv3mC9NNXQ/4rRjI/4Dj9+9XoDZeFEkF1SX1COSBXF1b2Pr9rqdEw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
'@oxlint/binding-linux-x64-musl@1.55.0':
resolution: {integrity: sha512-BDpP7W8GlaG7BR6QjGZAleYzxoyKc/D24spZIF2mB3XsfALQJJT/OBmP8YpeTb1rveFSBHzl8T7l0aqwkWNdGA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
'@oxlint/binding-openharmony-arm64@1.55.0':
resolution: {integrity: sha512-PS6GFvmde/pc3fCA2Srt51glr8Lcxhpf6WIBFfLphndjRrD34NEcses4TSxQrEcxYo6qVywGfylM0ZhSCF2gGA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxlint/binding-win32-arm64-msvc@1.55.0':
resolution: {integrity: sha512-P6JcLJGs/q1UOvDLzN8otd9JsH4tsuuPDv+p7aHqHM3PrKmYdmUvkNj4K327PTd35AYcznOCN+l4ZOaq76QzSw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxlint/binding-win32-ia32-msvc@1.55.0':
resolution: {integrity: sha512-gzkk4zE2zsE+WmRxFOiAZHpCpUNDFytEakqNXoNHW+PnYEOTPKDdW6nrzgSeTbGKVPXNAKQnRnMgrh7+n3Xueg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxlint/binding-win32-x64-msvc@1.55.0':
resolution: {integrity: sha512-ZFALNow2/og75gvYzNP7qe+rREQ5xunktwA+lgykoozHZ6hw9bqg4fn5j2UvG4gIn1FXqrZHkOAXuPf5+GOYTQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -874,6 +1030,12 @@ packages:
'@types/react-dom':
optional: true
'@testing-library/user-event@14.6.1':
resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
engines: {node: '>=12', npm: '>=6'}
peerDependencies:
'@testing-library/dom': '>=7.21.4'
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -1709,6 +1871,20 @@ packages:
oxc-resolver@11.19.1:
resolution: {integrity: sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==}
oxlint-tsgolint@0.16.0:
resolution: {integrity: sha512-4RuJK2jP08XwqtUu+5yhCbxEauCm6tv2MFHKEMsjbosK2+vy5us82oI3VLuHwbNyZG7ekZA26U2LLHnGR4frIA==}
hasBin: true
oxlint@1.55.0:
resolution: {integrity: sha512-T+FjepiyWpaZMhekqRpH8Z3I4vNM610p6w+Vjfqgj5TZUxHXl7N8N5IPvmOU8U4XdTRxqtNNTh9Y4hLtr7yvFg==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=0.15.0'
peerDependenciesMeta:
oxlint-tsgolint:
optional: true
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@@ -2011,8 +2187,8 @@ packages:
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
undici@7.22.0:
resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==}
undici@7.24.2:
resolution: {integrity: sha512-P9J1HWYV/ajFr8uCqk5QixwiRKmB1wOamgS0e+o2Z4A44Ej2+thFVRLG/eA7qprx88XXhnV5Bl8LHXTURpzB3Q==}
engines: {node: '>=20.18.1'}
universalify@2.0.1:
@@ -2305,39 +2481,39 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@biomejs/biome@2.0.0':
'@biomejs/biome@2.4.7':
optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.0.0
'@biomejs/cli-darwin-x64': 2.0.0
'@biomejs/cli-linux-arm64': 2.0.0
'@biomejs/cli-linux-arm64-musl': 2.0.0
'@biomejs/cli-linux-x64': 2.0.0
'@biomejs/cli-linux-x64-musl': 2.0.0
'@biomejs/cli-win32-arm64': 2.0.0
'@biomejs/cli-win32-x64': 2.0.0
'@biomejs/cli-darwin-arm64': 2.4.7
'@biomejs/cli-darwin-x64': 2.4.7
'@biomejs/cli-linux-arm64': 2.4.7
'@biomejs/cli-linux-arm64-musl': 2.4.7
'@biomejs/cli-linux-x64': 2.4.7
'@biomejs/cli-linux-x64-musl': 2.4.7
'@biomejs/cli-win32-arm64': 2.4.7
'@biomejs/cli-win32-x64': 2.4.7
'@biomejs/cli-darwin-arm64@2.0.0':
'@biomejs/cli-darwin-arm64@2.4.7':
optional: true
'@biomejs/cli-darwin-x64@2.0.0':
'@biomejs/cli-darwin-x64@2.4.7':
optional: true
'@biomejs/cli-linux-arm64-musl@2.0.0':
'@biomejs/cli-linux-arm64-musl@2.4.7':
optional: true
'@biomejs/cli-linux-arm64@2.0.0':
'@biomejs/cli-linux-arm64@2.4.7':
optional: true
'@biomejs/cli-linux-x64-musl@2.0.0':
'@biomejs/cli-linux-x64-musl@2.4.7':
optional: true
'@biomejs/cli-linux-x64@2.0.0':
'@biomejs/cli-linux-x64@2.4.7':
optional: true
'@biomejs/cli-win32-arm64@2.0.0':
'@biomejs/cli-win32-arm64@2.4.7':
optional: true
'@biomejs/cli-win32-x64@2.0.0':
'@biomejs/cli-win32-x64@2.4.7':
optional: true
'@bramus/specificity@2.4.2':
@@ -2611,6 +2787,81 @@ snapshots:
'@oxc-resolver/binding-win32-x64-msvc@11.19.1':
optional: true
'@oxlint-tsgolint/darwin-arm64@0.16.0':
optional: true
'@oxlint-tsgolint/darwin-x64@0.16.0':
optional: true
'@oxlint-tsgolint/linux-arm64@0.16.0':
optional: true
'@oxlint-tsgolint/linux-x64@0.16.0':
optional: true
'@oxlint-tsgolint/win32-arm64@0.16.0':
optional: true
'@oxlint-tsgolint/win32-x64@0.16.0':
optional: true
'@oxlint/binding-android-arm-eabi@1.55.0':
optional: true
'@oxlint/binding-android-arm64@1.55.0':
optional: true
'@oxlint/binding-darwin-arm64@1.55.0':
optional: true
'@oxlint/binding-darwin-x64@1.55.0':
optional: true
'@oxlint/binding-freebsd-x64@1.55.0':
optional: true
'@oxlint/binding-linux-arm-gnueabihf@1.55.0':
optional: true
'@oxlint/binding-linux-arm-musleabihf@1.55.0':
optional: true
'@oxlint/binding-linux-arm64-gnu@1.55.0':
optional: true
'@oxlint/binding-linux-arm64-musl@1.55.0':
optional: true
'@oxlint/binding-linux-ppc64-gnu@1.55.0':
optional: true
'@oxlint/binding-linux-riscv64-gnu@1.55.0':
optional: true
'@oxlint/binding-linux-riscv64-musl@1.55.0':
optional: true
'@oxlint/binding-linux-s390x-gnu@1.55.0':
optional: true
'@oxlint/binding-linux-x64-gnu@1.55.0':
optional: true
'@oxlint/binding-linux-x64-musl@1.55.0':
optional: true
'@oxlint/binding-openharmony-arm64@1.55.0':
optional: true
'@oxlint/binding-win32-arm64-msvc@1.55.0':
optional: true
'@oxlint/binding-win32-ia32-msvc@1.55.0':
optional: true
'@oxlint/binding-win32-x64-msvc@1.55.0':
optional: true
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -2789,6 +3040,10 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
dependencies:
'@testing-library/dom': 10.4.1
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1
@@ -3420,7 +3675,7 @@ snapshots:
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 6.0.0
undici: 7.22.0
undici: 7.24.2
w3c-xmlserializer: 5.0.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
@@ -3665,6 +3920,38 @@ snapshots:
'@oxc-resolver/binding-win32-ia32-msvc': 11.19.1
'@oxc-resolver/binding-win32-x64-msvc': 11.19.1
oxlint-tsgolint@0.16.0:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.16.0
'@oxlint-tsgolint/darwin-x64': 0.16.0
'@oxlint-tsgolint/linux-arm64': 0.16.0
'@oxlint-tsgolint/linux-x64': 0.16.0
'@oxlint-tsgolint/win32-arm64': 0.16.0
'@oxlint-tsgolint/win32-x64': 0.16.0
oxlint@1.55.0(oxlint-tsgolint@0.16.0):
optionalDependencies:
'@oxlint/binding-android-arm-eabi': 1.55.0
'@oxlint/binding-android-arm64': 1.55.0
'@oxlint/binding-darwin-arm64': 1.55.0
'@oxlint/binding-darwin-x64': 1.55.0
'@oxlint/binding-freebsd-x64': 1.55.0
'@oxlint/binding-linux-arm-gnueabihf': 1.55.0
'@oxlint/binding-linux-arm-musleabihf': 1.55.0
'@oxlint/binding-linux-arm64-gnu': 1.55.0
'@oxlint/binding-linux-arm64-musl': 1.55.0
'@oxlint/binding-linux-ppc64-gnu': 1.55.0
'@oxlint/binding-linux-riscv64-gnu': 1.55.0
'@oxlint/binding-linux-riscv64-musl': 1.55.0
'@oxlint/binding-linux-s390x-gnu': 1.55.0
'@oxlint/binding-linux-x64-gnu': 1.55.0
'@oxlint/binding-linux-x64-musl': 1.55.0
'@oxlint/binding-openharmony-arm64': 1.55.0
'@oxlint/binding-win32-arm64-msvc': 1.55.0
'@oxlint/binding-win32-ia32-msvc': 1.55.0
'@oxlint/binding-win32-x64-msvc': 1.55.0
oxlint-tsgolint: 0.16.0
package-json-from-dist@1.0.1: {}
parse5@8.0.0:
@@ -3973,7 +4260,7 @@ snapshots:
undici-types@7.18.2: {}
undici@7.22.0: {}
undici@7.24.2: {}
universalify@2.0.1: {}

View File

@@ -7,19 +7,36 @@ export default defineConfig({
coverage: {
provider: "v8",
enabled: true,
exclude: ["**/dist/**"],
thresholds: {
autoUpdate: true,
"packages/domain/src": {
lines: 96,
branches: 96,
lines: 99,
branches: 97,
},
"packages/application/src": {
lines: 97,
branches: 94,
},
"apps/web/src/adapters": {
lines: 71,
lines: 72,
branches: 78,
},
"apps/web/src/persistence": {
lines: 87,
branches: 67,
lines: 90,
branches: 71,
},
"apps/web/src/hooks": {
lines: 59,
branches: 85,
},
"apps/web/src/components": {
lines: 52,
branches: 64,
},
"apps/web/src/components/ui": {
lines: 73,
branches: 96,
},
},
},