28 Commits

Author SHA1 Message Date
Lukas
2971898f0c Add dark and light theme with OS preference support
All checks were successful
CI / check (push) Successful in 1m22s
CI / build-image (push) Successful in 36s
Follow OS color scheme by default, with a three-way toggle
(System / Light / Dark) in the kebab menu. Light theme uses warm,
neutral tones with soft card-to-background contrast. Semantic colors
(damage, healing, conditions) keep their hue across themes.

Closes #10

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 14:25:09 +01:00
82 changed files with 3644 additions and 941 deletions

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.

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

@@ -30,6 +30,8 @@ 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";
import { useTheme } from "./hooks/use-theme";
import { cn } from "./lib/utils";
function rollDice(): number {
return Math.floor(Math.random() * 20) + 1;
@@ -53,13 +55,12 @@ function useActionBarAnimation(combatantCount: number) {
}, [combatantCount]);
const empty = combatantCount === 0;
const risingClass = rising ? " animate-rise-to-center" : "";
const settlingClass = settling ? " animate-settle-to-bottom" : "";
const topBarClass = settling
? " animate-slide-down-in"
: topBarExiting
? " absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
: "";
const risingClass = rising ? "animate-rise-to-center" : "";
const settlingClass = settling ? "animate-settle-to-bottom" : "";
const exitingClass = topBarExiting
? "absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
: "";
const topBarClass = settling ? "animate-slide-down-in" : exitingClass;
const showTopBar = !empty || topBarExiting;
return {
@@ -115,8 +116,10 @@ export function App() {
const bulkImport = useBulkImport();
const sidePanel = useSidePanelState();
const { preference: themePreference, cycleTheme } = useTheme();
const [rollSkippedCount, setRollSkippedCount] = useState(0);
const [rollSingleSkipped, setRollSingleSkipped] = useState(false);
const selectedCreature: Creature | null = sidePanel.selectedCreatureId
? (getCreature(sidePanel.selectedCreatureId) ?? null)
@@ -128,9 +131,12 @@ export function App() {
const handleAddFromBestiary = useCallback(
(result: SearchResult) => {
addFromBestiary(result);
const creatureId = addFromBestiary(result);
if (creatureId && sidePanel.panelView.mode === "closed") {
sidePanel.showCreature(creatureId);
}
},
[addFromBestiary],
[addFromBestiary, sidePanel.panelView.mode, sidePanel.showCreature],
);
const handleCombatantStatBlock = useCallback(
@@ -142,9 +148,21 @@ export function App() {
const handleRollInitiative = useCallback(
(id: CombatantId) => {
rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature);
const result = rollInitiativeUseCase(
makeStore(),
id,
rollDice(),
getCreature,
);
if (isDomainError(result)) {
setRollSingleSkipped(true);
const combatant = encounter.combatants.find((c) => c.id === id);
if (combatant?.creatureId) {
sidePanel.showCreature(combatant.creatureId);
}
}
},
[makeStore, getCreature],
[makeStore, getCreature, encounter.combatants, sidePanel.showCreature],
);
const handleRollAllInitiative = useCallback(() => {
@@ -158,8 +176,8 @@ export function App() {
(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;
sidePanel.showCreature(cId);
},
@@ -187,6 +205,15 @@ export function App() {
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
// Auto-update stat block panel when the active combatant changes
const activeCreatureId =
encounter.combatants[encounter.activeIndex]?.creatureId;
useEffect(() => {
if (activeCreatureId && sidePanel.panelView.mode === "creature") {
sidePanel.showCreature(activeCreatureId);
}
}, [activeCreatureId, sidePanel.panelView.mode, sidePanel.showCreature]);
// Auto-scroll to the active combatant when the turn changes
const activeRowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -194,32 +221,14 @@ 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.
// Only react to activeIndex changes — not combatant reordering (e.g. Roll All).
const prevActiveIndexRef = useRef(encounter.activeIndex);
useEffect(() => {
if (prevActiveIndexRef.current === encounter.activeIndex) return;
prevActiveIndexRef.current = encounter.activeIndex;
if (!window.matchMedia("(min-width: 1024px)").matches) return;
const active = encounter.combatants[encounter.activeIndex];
if (!active?.creatureId || !isLoaded) return;
sidePanel.showCreature(active.creatureId as 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="flex h-dvh flex-col">
<div className="relative mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-3 px-4">
{!!actionBarAnim.showTopBar && (
<div
className={`shrink-0 pt-8${actionBarAnim.topBarClass}`}
className={cn("shrink-0 pt-8", actionBarAnim.topBarClass)}
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
>
<TurnNavigation
@@ -233,9 +242,9 @@ 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}`}
className={cn("w-full", actionBarAnim.risingClass)}
onAnimationEnd={actionBarAnim.onRiseEnd}
>
<ActionBar
@@ -256,6 +265,8 @@ export function App() {
showRollAllInitiative={hasCreatureCombatants}
rollAllInitiativeDisabled={!canRollAllInitiative}
onOpenSourceManager={sidePanel.showSourceManager}
themePreference={themePreference}
onCycleTheme={cycleTheme}
autoFocus
/>
</div>
@@ -263,7 +274,7 @@ export function App() {
) : (
<>
{/* 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
@@ -284,6 +295,9 @@ export function App() {
? () => handleCombatantStatBlock(c.creatureId as string)
: undefined
}
isStatBlockOpen={
c.creatureId === sidePanel.selectedCreatureId
}
onRollInitiative={
c.creatureId ? handleRollInitiative : undefined
}
@@ -294,7 +308,7 @@ export function App() {
{/* Action Bar — fixed at bottom */}
<div
className={`shrink-0 pb-8${actionBarAnim.settlingClass}`}
className={cn("shrink-0 pb-8", actionBarAnim.settlingClass)}
onAnimationEnd={actionBarAnim.onSettleEnd}
>
<ActionBar
@@ -315,6 +329,8 @@ export function App() {
showRollAllInitiative={hasCreatureCombatants}
rollAllInitiativeDisabled={!canRollAllInitiative}
onOpenSourceManager={sidePanel.showSourceManager}
themePreference={themePreference}
onCycleTheme={cycleTheme}
/>
</div>
</>
@@ -322,7 +338,7 @@ export function App() {
</div>
{/* Pinned Stat Block Panel (left) */}
{sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
{!!sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
<StatBlockPanel
creatureId={sidePanel.pinnedCreatureId}
creature={pinnedCreature}
@@ -378,6 +394,14 @@ export function App() {
/>
)}
{!!rollSingleSkipped && (
<Toast
message="Can't roll — bestiary source not loaded"
onDismiss={() => setRollSingleSkipped(false)}
autoDismissMs={4000}
/>
)}
<PlayerCharacterSection
ref={playerCharacterRef}
characters={playerCharacters}

View File

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

View File

@@ -200,6 +200,7 @@ describe("ConfirmButton", () => {
const parentHandler = vi.fn();
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,
@@ -92,7 +95,7 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
screen.getByRole("button", { name: "Collapse stat block panel" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /close/i }),
screen.queryByRole("button", { name: CLOSE_REGEX }),
).not.toBeInTheDocument();
});
@@ -247,7 +250,7 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
it("pinned panel has no collapse button", () => {
renderPanel({ panelRole: "pinned", side: "left" });
expect(
screen.queryByRole("button", { name: /collapse/i }),
screen.queryByRole("button", { name: COLLAPSE_REGEX }),
).not.toBeInTheDocument();
});

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

View File

@@ -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) => {
const name = ABILITY_MAP[ability.trim().toLowerCase()];
return name ? `${name} saving throw` : `${ability} saving throw`;
});
result = result.replaceAll(
/\{@actSave\s+([^}]+)\}/g,
(_, ability: string) => {
const name = ABILITY_MAP[ability.trim().toLowerCase()];
return name ? `${name} saving throw` : `${ability} saving throw`;
},
);
// {@actSaveFail} → "Failure:" or {@actSaveFail N} → "Failure by N or More:"
result = result.replace(
result = result.replaceAll(
/\{@actSaveFail\s+(\d+)\}/g,
"Failure by $1 or More:",
);
result = result.replace(/\{@actSaveFail\}/g, "Failure:");
result = result.replaceAll("{@actSaveFail}", "Failure:");
// {@actSaveSuccessOrFail} → keep as-is label
result = result.replace(/\{@actSaveSuccessOrFail\}/g, "Success or Failure:");
result = result.replaceAll("{@actSaveSuccessOrFail}", "Success or Failure:");
// {@actSaveFailBy N} → "Failure by N or More:"
result = result.replace(
result = result.replaceAll(
/\{@actSaveFailBy\s+(\d+)\}/g,
"Failure by $1 or More:",
);
@@ -81,7 +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-active-row-border");
});
it("unconscious combatant (currentHp === 0) gets dimmed styling", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
maxHp: 10,
currentHp: 0,
},
});
// The name area should have opacity-50
const nameEl = screen.getByText("Goblin");
const nameContainer = nameEl.closest(".opacity-50");
expect(nameContainer).not.toBeNull();
});
it("shows '--' for current HP when no maxHp is set", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
},
});
expect(screen.getByLabelText("No HP set")).toBeInTheDocument();
});
it("shows concentration icon when isConcentrating is true", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
isConcentrating: true,
},
});
const concButton = screen.getByRole("button", {
name: "Toggle concentration",
});
expect(concButton.className).toContain("text-purple-400");
});
it("shows player character icon and color when set", () => {
const { container } = renderRow({
combatant: {
id: combatantId("1"),
name: "Aragorn",
color: "red",
icon: "sword",
},
});
// The icon should be rendered with the player color
const svgIcon = container.querySelector("svg[style]");
expect(svgIcon).not.toBeNull();
expect(svgIcon).toHaveStyle({ color: PLAYER_COLOR_HEX.red });
});
it("remove button calls onRemove after confirmation", async () => {
const user = userEvent.setup();
const onRemove = vi.fn();
renderRow({ onRemove });
const removeBtn = screen.getByRole("button", {
name: "Remove combatant",
});
// First click enters confirm state
await user.click(removeBtn);
// Second click confirms
const confirmBtn = screen.getByRole("button", {
name: "Confirm remove combatant",
});
await user.click(confirmBtn);
expect(onRemove).toHaveBeenCalledWith(combatantId("1"));
});
it("shows d20 roll button when initiative is undefined and onRollInitiative is provided", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
},
onRollInitiative: vi.fn(),
});
expect(
screen.getByRole("button", { name: "Roll initiative" }),
).toBeInTheDocument();
});
});

View File

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

View File

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

View File

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

View File

@@ -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,
@@ -6,15 +6,13 @@ import {
Import,
Library,
Minus,
Monitor,
Moon,
Plus,
Sun,
Users,
} from "lucide-react";
import {
type FormEvent,
type RefObject,
useDeferredValue,
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";
@@ -48,6 +46,8 @@ interface ActionBarProps {
rollAllInitiativeDisabled?: boolean;
onOpenSourceManager?: () => void;
autoFocus?: boolean;
themePreference?: "system" | "light" | "dark";
onCycleTheme?: () => void;
}
function creatureKey(r: SearchResult): string {
@@ -67,7 +67,7 @@ function AddModeSuggestions({
onConfirmQueued,
onAddFromPlayerCharacter,
onClear,
}: {
}: Readonly<{
nameInput: string;
suggestions: SearchResult[];
pcMatches: PlayerCharacter[];
@@ -80,51 +80,49 @@ function AddModeSuggestions({
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">
<div className="card-glow absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-lg border border-border bg-card">
<button
type="button"
className="flex w-full items-center gap-1.5 border-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 = pc.icon
? PLAYER_ICON_MAP[pc.icon as PlayerIcon]
: undefined;
const PcIcon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
const pcColor = pc.color
? PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX]
? 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);
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>
@@ -144,19 +142,20 @@ 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={cn(
"flex w-full items-center justify-between px-3 py-1.5 text-left text-foreground text-sm",
isQueued && "bg-accent/30",
!isQueued && i === suggestionIndex && "bg-accent/20",
!isQueued &&
i !== suggestionIndex &&
"hover:bg-hover-neutral-bg",
)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onClickSuggestion(result)}
onMouseEnter={() => onSetSuggestionIndex(i)}
>
<span>{result.name}</span>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1 text-muted-foreground text-xs">
{isQueued ? (
<>
<button
@@ -177,7 +176,7 @@ function AddModeSuggestions({
>
<Minus className="h-3 w-3" />
</button>
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-foreground">
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground">
{queued.count}
</span>
<button
@@ -221,12 +220,26 @@ function AddModeSuggestions({
);
}
const THEME_ICONS = {
system: Monitor,
light: Sun,
dark: Moon,
} as const;
const THEME_LABELS = {
system: "Theme: System",
light: "Theme: Light",
dark: "Theme: Dark",
} as const;
function buildOverflowItems(opts: {
onManagePlayers?: () => void;
onOpenSourceManager?: () => void;
bestiaryLoaded: boolean;
onBulkImport?: () => void;
bulkImportDisabled?: boolean;
themePreference?: "system" | "light" | "dark";
onCycleTheme?: () => void;
}): OverflowMenuItem[] {
const items: OverflowMenuItem[] = [];
if (opts.onManagePlayers) {
@@ -251,6 +264,16 @@ function buildOverflowItems(opts: {
disabled: opts.bulkImportDisabled,
});
}
if (opts.onCycleTheme) {
const pref = opts.themePreference ?? "system";
const ThemeIcon = THEME_ICONS[pref];
items.push({
icon: <ThemeIcon className="h-4 w-4" />,
label: THEME_LABELS[pref],
onClick: opts.onCycleTheme,
keepOpen: true,
});
}
return items;
}
@@ -271,7 +294,9 @@ export function ActionBar({
rollAllInitiativeDisabled,
onOpenSourceManager,
autoFocus,
}: ActionBarProps) {
themePreference,
onCycleTheme,
}: Readonly<ActionBarProps>) {
const [nameInput, setNameInput] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
@@ -319,7 +344,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) {
@@ -460,10 +485,12 @@ export function ActionBar({
bestiaryLoaded,
onBulkImport,
bulkImportDisabled,
themePreference,
onCycleTheme,
});
return (
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
<form
onSubmit={handleAdd}
className="relative flex flex-1 items-center gap-2"
@@ -482,12 +509,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}
@@ -504,23 +531,24 @@ export function ActionBar({
</button>
)}
{browseMode && deferredSuggestions.length > 0 && (
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
<div className="card-glow absolute bottom-full z-50 mb-1 w-full rounded-lg border border-border bg-card">
<ul className="max-h-48 overflow-y-auto py-1">
{deferredSuggestions.map((result, i) => (
<li key={creatureKey(result)}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
className={cn(
"flex w-full items-center justify-between px-3 py-1.5 text-left text-sm",
i === suggestionIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
}`}
: "text-foreground hover:bg-hover-neutral-bg",
)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleBrowseSelect(result)}
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>
@@ -578,7 +606,7 @@ export function ActionBar({
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
<Button type="submit">Add</Button>
)}
{showRollAllInitiative && onRollAllInitiative && (
{showRollAllInitiative && !!onRollAllInitiative && (
<Button
type="button"
size="icon"

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,14 +18,15 @@ 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 onClick={onDone}>Done</Button>
@@ -54,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>
@@ -74,23 +75,20 @@ export function BulkImportPrompt({
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">
Import All Sources
</h3>
<p className="mt-1 text-xs text-muted-foreground">
<p className="mt-1 text-muted-foreground text-xs">
Load stat block data for all {totalSources} sources at once.
</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)}

View File

@@ -11,7 +11,7 @@ export function BulkImportToasts({
state,
visible,
onReset,
}: BulkImportToastsProps) {
}: Readonly<BulkImportToastsProps>) {
if (!visible) return null;
if (state.status === "loading") {

View File

@@ -9,7 +9,7 @@ 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) => (
@@ -20,7 +20,7 @@ export function ColorPalette({ value, onChange }: ColorPaletteProps) {
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

@@ -4,7 +4,7 @@ import {
deriveHpStatus,
type PlayerIcon,
} from "@initiative/domain";
import { Brain, X } from "lucide-react";
import { Book, BookOpen, Brain, X } from "lucide-react";
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
import { cn } from "../lib/utils";
import { AcShield } from "./ac-shield";
@@ -41,6 +41,7 @@ interface CombatantRowProps {
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
onToggleConcentration: (id: CombatantId) => void;
onShowStatBlock?: () => void;
isStatBlockOpen?: boolean;
onRollInitiative?: (id: CombatantId) => void;
}
@@ -48,21 +49,16 @@ function EditableName({
name,
combatantId,
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);
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const longPressTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const longPressTriggeredRef = useRef(false);
const commit = useCallback(() => {
const trimmed = draft.trim();
@@ -78,53 +74,13 @@ function EditableName({
requestAnimationFrame(() => inputRef.current?.select());
}, [name]);
useEffect(() => {
return () => {
clearTimeout(clickTimerRef.current);
clearTimeout(longPressTimerRef.current);
};
}, []);
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (longPressTriggeredRef.current) {
longPressTriggeredRef.current = false;
return;
}
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
clickTimerRef.current = undefined;
startEditing();
} else {
clickTimerRef.current = setTimeout(() => {
clickTimerRef.current = undefined;
onShowStatBlock?.();
}, 250);
}
},
[startEditing, onShowStatBlock],
);
const handleTouchStart = useCallback(() => {
longPressTriggeredRef.current = false;
longPressTimerRef.current = setTimeout(() => {
longPressTriggeredRef.current = true;
startEditing();
}, 500);
}, [startEditing]);
const cancelLongPress = useCallback(() => {
clearTimeout(longPressTimerRef.current);
}, []);
if (editing) {
return (
<Input
ref={inputRef}
type="text"
value={draft}
className="h-7 text-sm"
className="h-7 max-w-48 text-sm"
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
@@ -136,30 +92,24 @@ function EditableName({
}
return (
<>
<button
type="button"
onClick={handleClick}
onTouchStart={handleTouchStart}
onTouchEnd={cancelLongPress}
onTouchCancel={cancelLongPress}
onTouchMove={cancelLongPress}
className="truncate text-left text-sm text-foreground cursor-text hover:text-hover-neutral transition-colors"
style={color ? { color } : undefined}
>
{name}
</button>
</>
<button
type="button"
onClick={startEditing}
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 +155,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 +167,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 +180,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>
@@ -244,8 +196,9 @@ function ClickableHp({
<button
type="button"
onClick={() => setPopoverOpen(true)}
aria-label={`Current HP: ${currentHp} (${status})`}
className={cn(
"inline-block h-7 min-w-[3ch] text-center text-sm font-medium leading-7 tabular-nums transition-colors hover:text-hover-neutral",
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm 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 +207,7 @@ function ClickableHp({
>
{currentHp}
</button>
{popoverOpen && (
{!!popoverOpen && (
<HpAdjustPopover
onAdjust={onAdjust}
onClose={() => setPopoverOpen(false)}
@@ -267,10 +220,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 +274,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 +350,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",
)}
>
@@ -413,9 +366,13 @@ function rowBorderClass(
isActive: boolean,
isConcentrating: boolean | undefined,
): string {
if (isActive) return "border-l-2 border-l-accent bg-accent/10";
if (isConcentrating) return "border-l-2 border-l-purple-400";
return "border-l-2 border-l-transparent";
if (isActive && isConcentrating)
return "border border-l-2 border-active-row-border border-l-purple-400 bg-active-row-bg card-glow";
if (isActive)
return "border border-l-2 border-active-row-border bg-active-row-bg card-glow";
if (isConcentrating)
return "border border-l-2 border-transparent border-l-purple-400";
return "border border-l-2 border-transparent";
}
function concentrationIconClass(
@@ -427,17 +384,6 @@ function concentrationIconClass(
return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400";
}
function activateOnKeyDown(
handler: () => void,
): (e: { key: string; preventDefault: () => void }) => void {
return (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handler();
}
};
}
export function CombatantRow({
ref,
combatant,
@@ -451,6 +397,7 @@ export function CombatantRow({
onToggleCondition,
onToggleConcentration,
onShowStatBlock,
isStatBlockOpen,
onRollInitiative,
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
const { id, name, initiative, maxHp, currentHp } = combatant;
@@ -490,34 +437,23 @@ export function CombatantRow({
: undefined;
return (
/* biome-ignore lint/a11y/noStaticElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
<div
ref={ref}
role={onShowStatBlock ? "button" : undefined}
tabIndex={onShowStatBlock ? 0 : undefined}
className={cn(
"group rounded-md pr-3 transition-colors",
"group rounded-lg pr-3 transition-colors",
rowBorderClass(isActive, combatant.isConcentrating),
isPulsing && "animate-concentration-pulse",
onShowStatBlock && "cursor-pointer",
)}
onClick={onShowStatBlock}
onKeyDown={
onShowStatBlock ? activateOnKeyDown(onShowStatBlock) : undefined
}
>
<div className="grid grid-cols-[2rem_3rem_1fr_auto_auto_2rem] items-center gap-3 py-2">
{/* Concentration */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleConcentration(id);
}}
onClick={() => onToggleConcentration(id)}
title="Concentrating"
aria-label="Toggle concentration"
className={cn(
"flex w-full items-center justify-center self-stretch -my-2 -ml-[2px] pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
"-my-2 -ml-[2px] flex w-full items-center justify-center self-stretch pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
concentrationIconClass(combatant.isConcentrating, dimmed),
)}
>
@@ -525,39 +461,44 @@ export function CombatantRow({
</button>
{/* Initiative */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
<div
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<InitiativeDisplay
initiative={initiative}
combatantId={id}
dimmed={dimmed}
onSetInitiative={onSetInitiative}
onRollInitiative={onRollInitiative}
/>
</div>
<InitiativeDisplay
initiative={initiative}
combatantId={id}
dimmed={dimmed}
onSetInitiative={onSetInitiative}
onRollInitiative={onRollInitiative}
/>
{/* 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 &&
{!!onShowStatBlock && (
<button
type="button"
onClick={onShowStatBlock}
title="View stat block"
aria-label="View stat block"
className="shrink-0 text-foreground transition-colors hover:text-hover-neutral"
>
{isStatBlockOpen ? <BookOpen size={16} /> : <Book size={16} />}
</button>
)}
{!!combatant.icon &&
!!combatant.color &&
(() => {
const PcIcon = PLAYER_ICON_MAP[combatant.icon as PlayerIcon];
const pcColor =
const iconColor =
PLAYER_COLOR_HEX[
combatant.color as keyof typeof PLAYER_COLOR_HEX
];
return PcIcon ? (
<PcIcon
size={14}
style={{ color: pcColor }}
size={16}
style={{ color: iconColor }}
className="shrink-0"
/>
) : null;
@@ -566,7 +507,6 @@ export function CombatantRow({
name={name}
combatantId={id}
onRename={onRename}
onShowStatBlock={onShowStatBlock}
color={pcColor}
/>
<ConditionTags
@@ -574,7 +514,7 @@ export function CombatantRow({
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
onOpenPicker={() => setPickerOpen((prev) => !prev)}
/>
{pickerOpen && (
{!!pickerOpen && (
<ConditionPicker
activeConditions={combatant.conditions}
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
@@ -584,22 +524,12 @@ export function CombatantRow({
</div>
{/* AC */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
<div
className={cn(dimmed && "opacity-50")}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<div className={cn(dimmed && "opacity-50")}>
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
</div>
{/* HP */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
<div
className="flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-1">
<ClickableHp
currentHp={currentHp}
maxHp={maxHp}
@@ -609,7 +539,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 +556,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);
@@ -97,7 +97,7 @@ export function ConditionPicker({
<div
ref={ref}
className={cn(
"absolute left-0 z-10 w-fit overflow-y-auto rounded-md border border-border bg-background p-1 shadow-lg",
"card-glow absolute left-0 z-10 w-fit overflow-y-auto rounded-lg border border-border bg-background p-1",
flipped ? "bottom-full mb-1" : "top-full mt-1",
)}
style={maxHeight ? { maxHeight } : undefined}

View File

@@ -18,6 +18,7 @@ import {
Sparkles,
ZapOff,
} from "lucide-react";
import { cn } from "../lib/utils.js";
const ICON_MAP: Record<string, LucideIcon> = {
EyeOff,
@@ -60,7 +61,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 +76,10 @@ 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={cn(
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
colorClass,
)}
onClick={(e) => {
e.stopPropagation();
onRemove(condId);
@@ -89,7 +93,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, useRef, useState } from "react";
import { ColorPalette } from "./color-palette";
import { IconGrid } from "./icon-grid";
import { Button } from "./ui/button";
@@ -24,7 +24,8 @@ export function CreatePlayerModal({
onClose,
onSave,
playerCharacter,
}: CreatePlayerModalProps) {
}: Readonly<CreatePlayerModalProps>) {
const dialogRef = useRef<HTMLDialogElement>(null);
const [name, setName] = useState("");
const [ac, setAc] = useState("10");
const [maxHp, setMaxHp] = useState("10");
@@ -54,17 +55,34 @@ export function CreatePlayerModal({
}, [open, playerCharacter]);
useEffect(() => {
if (!open) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) {
dialog.showModal();
} else if (!open && dialog.open) {
dialog.close();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, onClose]);
}, [open]);
if (!open) return null;
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
function handleCancel(e: Event) {
e.preventDefault();
onClose();
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === dialog) onClose();
}
dialog.addEventListener("cancel", handleCancel);
dialog.addEventListener("mousedown", handleBackdropClick);
return () => {
dialog.removeEventListener("cancel", handleCancel);
dialog.removeEventListener("mousedown", handleBackdropClick);
};
}, [onClose]);
const handleSubmit = (e: FormEvent) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
const trimmed = name.trim();
if (trimmed === "") {
@@ -86,102 +104,89 @@ export function CreatePlayerModal({
};
return (
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onMouseDown={onClose}
<dialog
ref={dialogRef}
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
<div
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">
{isEdit ? "Edit Player" : "Create Player"}
</h2>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="text-muted-foreground"
>
<X size={20} />
</Button>
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold text-foreground text-lg">
{isEdit ? "Edit Player" : "Create Player"}
</h2>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="text-muted-foreground"
>
<X size={20} />
</Button>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<span className="mb-1 block text-muted-foreground text-sm">Name</span>
<Input
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
setError("");
}}
placeholder="Character name"
aria-label="Name"
autoFocus
/>
{!!error && <p className="mt-1 text-destructive text-sm">{error}</p>}
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<span className="mb-1 block text-sm text-muted-foreground">
Name
<div className="flex gap-3">
<div className="flex-1">
<span className="mb-1 block text-muted-foreground text-sm">AC</span>
<Input
type="text"
inputMode="numeric"
value={ac}
onChange={(e) => setAc(e.target.value)}
placeholder="AC"
aria-label="AC"
className="text-center"
/>
</div>
<div className="flex-1">
<span className="mb-1 block text-muted-foreground text-sm">
Max HP
</span>
<Input
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
setError("");
}}
placeholder="Character name"
aria-label="Name"
autoFocus
inputMode="numeric"
value={maxHp}
onChange={(e) => setMaxHp(e.target.value)}
placeholder="Max HP"
aria-label="Max HP"
className="text-center"
/>
{error && <p className="mt-1 text-sm text-destructive">{error}</p>}
</div>
</div>
<div className="flex gap-3">
<div className="flex-1">
<span className="mb-1 block text-sm text-muted-foreground">
AC
</span>
<Input
type="text"
inputMode="numeric"
value={ac}
onChange={(e) => setAc(e.target.value)}
placeholder="AC"
aria-label="AC"
className="text-center"
/>
</div>
<div className="flex-1">
<span className="mb-1 block text-sm text-muted-foreground">
Max HP
</span>
<Input
type="text"
inputMode="numeric"
value={maxHp}
onChange={(e) => setMaxHp(e.target.value)}
placeholder="Max HP"
aria-label="Max HP"
className="text-center"
/>
</div>
</div>
<div>
<span className="mb-2 block text-muted-foreground text-sm">
Color
</span>
<ColorPalette value={color} onChange={setColor} />
</div>
<div>
<span className="mb-2 block text-sm text-muted-foreground">
Color
</span>
<ColorPalette value={color} onChange={setColor} />
</div>
<div>
<span className="mb-2 block text-muted-foreground text-sm">Icon</span>
<IconGrid value={icon} onChange={setIcon} />
</div>
<div>
<span className="mb-2 block text-sm text-muted-foreground">
Icon
</span>
<IconGrid value={icon} onChange={setIcon} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
</div>
</form>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
</div>
</form>
</dialog>
);
}

View File

@@ -8,6 +8,8 @@ import {
} from "react";
import { Input } from "./ui/input";
const DIGITS_ONLY_REGEX = /^\d+$/;
interface HpAdjustPopoverProps {
readonly onAdjust: (delta: number) => void;
readonly onClose: () => void;
@@ -85,7 +87,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
return (
<div
ref={ref}
className="fixed z-10 rounded-md border border-border bg-background p-2 shadow-lg"
className="card-glow fixed z-10 rounded-lg border border-border bg-background p-2"
style={
pos
? { top: pos.top, left: pos.left }
@@ -102,7 +104,7 @@ 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);
}
}}
@@ -111,7 +113,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
<button
type="button"
disabled={!isValid}
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"
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-red-400 transition-colors hover:bg-hp-damage-hover-bg hover:text-red-300 disabled:pointer-events-none disabled:opacity-50"
onClick={() => applyDelta(-1)}
title="Apply damage"
aria-label="Apply damage"
@@ -121,7 +123,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
<button
type="button"
disabled={!isValid}
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"
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-emerald-400 transition-colors hover:bg-hp-heal-hover-bg hover:text-emerald-300 disabled:pointer-events-none disabled:opacity-50"
onClick={() => applyDelta(1)}
title="Apply healing"
aria-label="Apply healing"

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) => {
@@ -23,7 +23,7 @@ export function IconGrid({ value, onChange }: IconGridProps) {
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

@@ -1,5 +1,5 @@
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
import { forwardRef, useImperativeHandle, useState } from "react";
import { type RefObject, useImperativeHandle, useState } from "react";
import { CreatePlayerModal } from "./create-player-modal.js";
import { PlayerManagement } from "./player-management.js";
@@ -29,13 +29,15 @@ interface PlayerCharacterSectionProps {
onDeleteCharacter: (id: PlayerCharacterId) => void;
}
export const PlayerCharacterSection = forwardRef<
PlayerCharacterSectionHandle,
PlayerCharacterSectionProps
>(function PlayerCharacterSection(
{ characters, onCreateCharacter, onEditCharacter, onDeleteCharacter },
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<
@@ -88,4 +90,4 @@ export const PlayerCharacterSection = forwardRef<
/>
</>
);
});
};

View File

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

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)}
@@ -97,7 +98,7 @@ 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
variant="outline"
@@ -117,7 +118,7 @@ export function SourceFetchPrompt({
</div>
{status === "error" && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-destructive text-xs">
{error}
</div>
)}

View File

@@ -1,15 +1,25 @@
import { Database, Trash2 } from "lucide-react";
import { useCallback, useEffect, useOptimistic, useState } from "react";
import { Database, Search, Trash2 } from "lucide-react";
import {
useCallback,
useEffect,
useMemo,
useOptimistic,
useState,
} from "react";
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
import * as bestiaryCache from "../adapters/bestiary-cache.js";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
interface SourceManagerProps {
onCacheCleared: () => void;
}
export function SourceManager({ onCacheCleared }: SourceManagerProps) {
export function SourceManager({
onCacheCleared,
}: Readonly<SourceManagerProps>) {
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
const [filter, setFilter] = useState("");
const [optimisticSources, applyOptimistic] = useOptimistic(
sources,
(
@@ -27,7 +37,7 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
}, []);
useEffect(() => {
loadSources();
void loadSources();
}, [loadSources]);
const handleClearSource = async (sourceCode: string) => {
@@ -44,11 +54,20 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
onCacheCleared();
};
const filteredSources = useMemo(() => {
const term = filter.toLowerCase();
return term
? optimisticSources.filter((s) =>
s.displayName.toLowerCase().includes(term),
)
: optimisticSources;
}, [optimisticSources, filter]);
if (optimisticSources.length === 0) {
return (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<Database className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No cached sources</p>
<p className="text-muted-foreground text-sm">No cached sources</p>
</div>
);
}
@@ -56,29 +75,38 @@ 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
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" />
Clear All
</Button>
</div>
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Filter sources…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="pl-8"
/>
</div>
<ul className="flex flex-col gap-1">
{optimisticSources.map((source) => (
{filteredSources.map((source) => (
<li
key={source.sourceCode}
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
>
<div>
<span className="text-sm text-foreground">
<span className="text-foreground text-sm">
{source.displayName}
</span>
<span className="ml-2 text-xs text-muted-foreground">
<span className="ml-2 text-muted-foreground text-xs">
{source.creatureCount} creatures
</span>
</div>
@@ -86,6 +114,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

@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
import type { BulkImportState } from "../hooks/use-bulk-import.js";
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
import { cn } from "../lib/utils.js";
import { BulkImportPrompt } from "./bulk-import-prompt.js";
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
import { SourceManager } from "./source-manager.js";
@@ -46,21 +47,22 @@ function CollapsedTab({
creatureName,
side,
onToggleCollapse,
}: {
}: Readonly<{
creatureName: string;
side: "left" | "right";
onToggleCollapse: () => void;
}) {
}>) {
return (
<button
type="button"
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"
}`}
className={cn(
"flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral",
side === "right" ? "self-start" : "self-end",
)}
aria-label="Expand stat block panel"
>
<span className="writing-vertical-rl text-sm font-medium">
<span className="writing-vertical-rl font-medium text-sm">
{creatureName}
</span>
</button>
@@ -73,15 +75,15 @@ function PanelHeader({
onToggleCollapse,
onPin,
onUnpin,
}: {
}: Readonly<{
panelRole: "browse" | "pinned";
showPinButton: boolean;
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
@@ -133,7 +135,7 @@ function DesktopPanel({
onPin,
onUnpin,
children,
}: {
}: Readonly<{
isCollapsed: boolean;
side: "left" | "right";
creatureName: string;
@@ -143,7 +145,7 @@ function DesktopPanel({
onPin: () => void;
onUnpin: () => void;
children: ReactNode;
}) {
}>) {
const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l";
const collapsedTranslate =
side === "right"
@@ -152,7 +154,11 @@ function DesktopPanel({
return (
<div
className={`fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel ${sideClasses} ${isCollapsed ? collapsedTranslate : "translate-x-0"}`}
className={cn(
"panel-glow fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel",
sideClasses,
isCollapsed ? collapsedTranslate : "translate-x-0",
)}
>
{isCollapsed ? (
<CollapsedTab
@@ -179,28 +185,31 @@ function DesktopPanel({
function MobileDrawer({
onDismiss,
children,
}: {
}: Readonly<{
onDismiss: () => void;
children: ReactNode;
}) {
}>) {
const { offsetX, isSwiping, handlers } = useSwipeToDismiss(onDismiss);
return (
<div className="fixed inset-0 z-50">
<button
type="button"
className="absolute inset-0 bg-black/50 animate-in fade-in"
className="fade-in absolute inset-0 animate-in bg-black/50"
onClick={onDismiss}
aria-label="Close stat block"
/>
<div
className={`absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-l border-border bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`}
className={cn(
"panel-glow absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-border border-l bg-card",
!isSwiping && "animate-slide-in-right",
)}
style={
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
}
{...handlers}
>
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex items-center justify-between border-border border-b px-4 py-2">
<Button
variant="ghost"
size="icon-sm"
@@ -239,15 +248,15 @@ export function StatBlockPanel({
onStartBulkImport,
onBulkImportDone,
sourceManagerMode,
}: StatBlockPanelProps) {
}: 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);
@@ -266,7 +275,7 @@ export function StatBlockPanel({
}
setCheckingCache(true);
isSourceCached(sourceCode).then((cached) => {
void isSourceCached(sourceCode).then((cached) => {
setNeedsFetch(!cached);
setCheckingCache(false);
});
@@ -303,7 +312,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>
);
}
@@ -324,19 +333,16 @@ 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 ??
(sourceManagerMode
? "Sources"
: bulkImportMode
? "Import All Sources"
: "Creature");
let fallbackName = "Creature";
if (sourceManagerMode) fallbackName = "Sources";
else if (bulkImportMode) fallbackName = "Import All Sources";
const creatureName = creature?.name ?? fallbackName;
if (isDesktop) {
return (
@@ -355,7 +361,7 @@ export function StatBlockPanel({
);
}
if (panelRole === "pinned") return null;
if (panelRole === "pinned" || isCollapsed) return null;
return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
}

View File

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

View File

@@ -24,8 +24,8 @@ export function Toast({
return createPortal(
<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>
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
<span className="text-foreground text-sm">{message}</span>
{progress !== undefined && (
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
<div

View File

@@ -15,15 +15,15 @@ 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];
return (
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
<Button
variant="outline"
variant="ghost"
size="icon"
onClick={onRetreatTurn}
disabled={!hasCombatants || isAtStart}
@@ -33,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 ? (
@@ -53,7 +53,7 @@ export function TurnNavigation({
className="text-muted-foreground"
/>
<Button
variant="outline"
variant="ghost"
size="icon"
onClick={onAdvanceTurn}
disabled={!hasCombatants}

View File

@@ -9,8 +9,9 @@ const buttonVariants = cva(
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
outline:
"border border-border bg-transparent hover:bg-hover-neutral-bg hover:text-hover-neutral",
ghost: "hover:bg-hover-neutral-bg hover:text-hover-neutral",
"border border-border bg-background/50 text-foreground hover:bg-hover-neutral-bg hover:text-hover-neutral",
ghost:
"text-foreground hover:bg-hover-neutral-bg hover:text-hover-neutral",
},
size: {
default: "h-8 px-3 text-xs",

View File

@@ -55,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]);
@@ -100,7 +100,7 @@ export function ConfirmButton({
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}
@@ -110,7 +110,8 @@ export function ConfirmButton({
aria-label={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
title={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
>
{isConfirming ? <Check size={16} /> : icon}
{isConfirming ? <Check size={16} /> : null}
{!isConfirming && icon}
</Button>
</div>
);

View File

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

@@ -7,6 +7,7 @@ export interface OverflowMenuItem {
readonly label: string;
readonly onClick: () => void;
readonly disabled?: boolean;
readonly keepOpen?: boolean;
}
interface OverflowMenuProps {
@@ -48,17 +49,17 @@ 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="card-glow absolute right-0 bottom-full z-50 mb-1 min-w-48 rounded-lg border border-border bg-card py-1">
{items.map((item) => (
<button
key={item.label}
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-hover-neutral-bg 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();
setOpen(false);
if (!item.keepOpen) setOpen(false);
}}
>
{item.icon}

View File

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

View File

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

View File

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

View File

@@ -44,7 +44,7 @@ export function useBestiary(): BestiaryHook {
setIsLoaded(true);
}
bestiaryCache.loadAllCachedCreatures().then((map) => {
void bestiaryCache.loadAllCachedCreatures().then((map) => {
setCreatureMap(map);
});
}, []);

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

View File

@@ -17,6 +17,7 @@ import type {
BestiaryIndexEntry,
CombatantId,
ConditionId,
CreatureId,
DomainEvent,
Encounter,
PlayerCharacter,
@@ -33,6 +34,8 @@ import {
saveEncounter,
} from "../persistence/encounter-storage.js";
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
const EMPTY_ENCOUNTER: Encounter = {
combatants: [],
activeIndex: 0,
@@ -48,7 +51,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;
@@ -263,7 +266,7 @@ export function useEncounter() {
}, [makeStore]);
const addFromBestiary = useCallback(
(entry: BestiaryIndexEntry) => {
(entry: BestiaryIndexEntry): CreatureId | null => {
const store = makeStore();
const existingNames = store.get().combatants.map((c) => c.name);
const { newName, renames } = resolveCreatureName(
@@ -282,7 +285,7 @@ export function useEncounter() {
// Add combatant with resolved name
const id = combatantId(`c-${++nextId.current}`);
const addResult = addCombatantUseCase(makeStore(), id, newName);
if (isDomainError(addResult)) return;
if (isDomainError(addResult)) return null;
// Set HP
const hpResult = setHpUseCase(makeStore(), id, entry.hp);
@@ -301,8 +304,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)
@@ -315,8 +318,10 @@ export function useEncounter() {
});
setEvents((prev) => [...prev, ...addResult]);
return cId;
},
[makeStore, editCombatant],
[makeStore],
);
const addFromPlayerCharacter = useCallback(
@@ -368,7 +373,7 @@ export function useEncounter() {
setEvents((prev) => [...prev, ...addResult]);
},
[makeStore, editCombatant],
[makeStore],
);
const isEmpty = encounter.combatants.length === 0;

View File

@@ -34,11 +34,11 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
null,
);
const [isWideDesktop, setIsWideDesktop] = useState(
() => window.matchMedia("(min-width: 1280px)").matches,
() => globalThis.matchMedia("(min-width: 1280px)").matches,
);
useEffect(() => {
const mq = window.matchMedia("(min-width: 1280px)");
const mq = globalThis.matchMedia("(min-width: 1280px)");
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);

View File

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

View File

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

View File

@@ -1,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

@@ -7,11 +7,13 @@
}
},
"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"
},
@@ -26,6 +28,9 @@
"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:ignores": "node scripts/check-lint-ignores.mjs",
"check:classnames": "node scripts/check-cn-classnames.mjs",
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && tsc --build && vitest run && jscpd"
}
}

View File

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

View File

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

@@ -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) {
expect(activeIndex).toBeGreaterThanOrEqual(0);
expect(activeIndex).toBeLessThan(combatants.length);
} else {
expect(activeIndex).toBe(0);
}
// After adding a combatant, list is always non-empty
expect(combatants.length).toBeGreaterThan(0);
expect(activeIndex).toBeGreaterThanOrEqual(0);
expect(activeIndex).toBeLessThan(combatants.length);
}
});
@@ -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,7 @@ 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", () => {

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", () => {

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

@@ -23,7 +23,7 @@ interface EditFields {
}
function validateFields(fields: EditFields): DomainError | null {
if (fields.name !== undefined && fields.name.trim() === "") {
if (fields.name?.trim() === "") {
return {
kind: "domain-error",
code: "invalid-name",
@@ -81,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"]) ?? undefined)
: existing.color,
fields.color === undefined
? existing.color
: ((fields.color as PlayerCharacter["color"]) ?? undefined),
icon:
fields.icon !== undefined
? ((fields.icon as PlayerCharacter["icon"]) ?? undefined)
: existing.icon,
fields.icon === undefined
? existing.icon
: ((fields.icon as PlayerCharacter["icon"]) ?? undefined),
};
}

View File

@@ -21,14 +21,12 @@ export function setAc(
};
}
if (value !== undefined) {
if (!Number.isInteger(value) || value < 0) {
return {
kind: "domain-error",
code: "invalid-ac",
message: `AC must be a non-negative integer, got ${value}`,
};
}
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];

View File

@@ -28,14 +28,12 @@ export function setHp(
};
}
if (maxHp !== undefined) {
if (!Number.isInteger(maxHp) || maxHp < 1) {
return {
kind: "domain-error",
code: "invalid-max-hp",
message: `Max HP must be a positive integer, got ${maxHp}`,
};
}
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];

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;

358
pnpm-lock.yaml generated
View File

@@ -12,8 +12,8 @@ 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))
@@ -26,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
@@ -72,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
@@ -212,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]
@@ -632,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'}
@@ -877,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==}
@@ -1712,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==}
@@ -2308,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':
@@ -2614,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
@@ -2792,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
@@ -3668,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:

View File

@@ -0,0 +1,47 @@
/**
* Ban template-literal classNames in TSX files.
*
* Tailwind v4's production content extractor does static analysis on source
* files to discover utility classes. Template literals like
* className={`foo ${bar}`}
* can cause the extractor to miss classes adjacent to `${`, leading to
* styles that work in dev (JIT) but break in production.
*
* Rule: always use cn() for dynamic class composition instead.
*/
import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";
const PATTERN = /className\s*=\s*\{`/;
function findFiles() {
return execSync("git ls-files -- '*.tsx'", { encoding: "utf-8" })
.trim()
.split("\n")
.filter(Boolean);
}
let errors = 0;
for (const file of findFiles()) {
const lines = readFileSync(file, "utf-8").split("\n");
for (let i = 0; i < lines.length; i++) {
if (PATTERN.test(lines[i])) {
console.error(
`${file}:${i + 1}: className uses template literal — use cn() instead`,
);
errors++;
}
}
}
if (errors > 0) {
console.error(
`\n${errors} template-literal className(s) found. Use cn() for dynamic classes.`,
);
process.exit(1);
} else {
console.log("No template-literal classNames found.");
}

View File

@@ -0,0 +1,108 @@
/**
* Backpressure check for biome-ignore comments.
*
* 1. Ratcheting cap — source and test files have separate max counts.
* Lower these numbers as you fix ignores; they can never go up silently.
* 2. Banned rules — ignoring certain rule categories is never allowed.
* 3. Justification — every ignore must have a non-empty explanation after
* the rule name.
*/
import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";
// ── Configuration ──────────────────────────────────────────────────────
const MAX_SOURCE_IGNORES = 2;
const MAX_TEST_IGNORES = 3;
/** Rule prefixes that must never be suppressed. */
const BANNED_PREFIXES = [
"lint/security/",
"lint/correctness/noGlobalObjectCalls",
"lint/correctness/noUnsafeFinally",
];
// ───────────────────────────────────────────────────────────────────────
const IGNORE_PATTERN = /biome-ignore\s+([\w/]+)(?::\s*(.*))?/;
function findFiles() {
return execSync("git ls-files -- '*.ts' '*.tsx'", { encoding: "utf-8" })
.trim()
.split("\n")
.filter(Boolean);
}
function isTestFile(path) {
return (
path.includes("__tests__/") ||
path.endsWith(".test.ts") ||
path.endsWith(".test.tsx")
);
}
let errors = 0;
let sourceCount = 0;
let testCount = 0;
for (const file of findFiles()) {
const lines = readFileSync(file, "utf-8").split("\n");
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(IGNORE_PATTERN);
if (!match) continue;
const rule = match[1];
const justification = (match[2] ?? "").trim();
const loc = `${file}:${i + 1}`;
// Count by category
if (isTestFile(file)) {
testCount++;
} else {
sourceCount++;
}
// Banned rules
for (const prefix of BANNED_PREFIXES) {
if (rule.startsWith(prefix)) {
console.error(`BANNED: ${loc}${rule} must not be suppressed`);
errors++;
}
}
// Justification required
if (!justification) {
console.error(
`MISSING JUSTIFICATION: ${loc} — biome-ignore ${rule} needs an explanation after the colon`,
);
errors++;
}
}
}
// Ratcheting caps
if (sourceCount > MAX_SOURCE_IGNORES) {
console.error(
`SOURCE CAP EXCEEDED: ${sourceCount} biome-ignore comments in source (max ${MAX_SOURCE_IGNORES}). Fix issues and lower the cap.`,
);
errors++;
}
if (testCount > MAX_TEST_IGNORES) {
console.error(
`TEST CAP EXCEEDED: ${testCount} biome-ignore comments in tests (max ${MAX_TEST_IGNORES}). Fix issues and lower the cap.`,
);
errors++;
}
// Summary
console.log(
`biome-ignore: ${sourceCount} source (max ${MAX_SOURCE_IGNORES}), ${testCount} test (max ${MAX_TEST_IGNORES})`,
);
if (errors > 0) {
console.error(`\n${errors} problem(s) found.`);
process.exit(1);
} else {
console.log("All checks passed.");
}

View File

@@ -116,19 +116,17 @@ A user attempts to edit a combatant that no longer exists or provides an invalid
**Story C3 — Rename trigger UX (Priority: P1)**
A user wants to rename a combatant. Single-clicking the name opens the stat block panel instead of entering edit mode. To rename, the user double-clicks the name or long-presses on touch devices. A `cursor-text` cursor on hover signals that the name is editable.
A user wants to rename a combatant. Clicking the combatant's name immediately enters inline edit mode — no delay, no timer, consistent for all combatant types. A `cursor-text` cursor on hover signals that the name is editable. Stat block access is handled separately via a dedicated book icon (see `specs/004-bestiary/spec.md`, FR-062).
**Acceptance Scenarios**:
1. **Given** a combatant row is visible, **When** the user single-clicks the combatant name, **Then** the stat block panel opens or toggles — inline edit mode is NOT entered.
1. **Given** a combatant row is visible, **When** the user clicks the combatant name, **Then** inline edit mode is entered immediately for that combatant's name — no delay or timer.
2. **Given** a combatant row is visible, **When** the user double-clicks the combatant name, **Then** inline edit mode is entered for that combatant's name.
2. **Given** a combatant row is visible on a pointer device, **When** the user hovers over the combatant name, **Then** the cursor changes to a text cursor (`cursor-text`) to signal editability.
3. **Given** a combatant row is visible on a pointer device, **When** the user hovers over the combatant name, **Then** the cursor changes to a text cursor (`cursor-text`) to signal editability.
3. **Given** inline edit mode has been entered, **When** the user types a new name and presses Enter or blurs the field, **Then** the name is committed. **When** the user presses Escape, **Then** the edit is cancelled and the original name is restored.
4. **Given** a combatant row is visible on a touch device, **When** the user long-presses the combatant name, **Then** inline edit mode is entered for that combatant's name.
7. **Given** inline edit mode has been entered (via any trigger), **When** the user types a new name and presses Enter or blurs the field, **Then** the name is committed. **When** the user presses Escape, **Then** the edit is cancelled and the original name is restored.
4. **Given** a bestiary combatant row and a custom combatant row, **When** the user clicks either combatant's name, **Then** the behavior is identical — inline edit mode is entered immediately in both cases.
---
@@ -291,7 +289,7 @@ EditCombatant MUST return an `"invalid-name"` error when the new name is empty o
EditCombatant MUST preserve the combatant's position in the list, `activeIndex`, and `roundNumber`. Setting a name to the same value it already has is treated as a valid update; a `CombatantUpdated` event is still emitted.
#### FR-024 — Edit: UI
The UI MUST provide an inline name-edit mechanism for each combatant, activated by double-clicking the name or long-pressing on touch devices. The name MUST display a `cursor-text` cursor on hover to signal editability. Single-clicking the name MUST open/toggle the stat block panel, not enter edit mode. The updated name MUST be immediately visible after submission.
The UI MUST provide an inline name-edit mechanism for each combatant, activated by a single click on the name. Clicking the name MUST enter inline edit mode immediately — no delay, no timer, consistent for all combatant types. The name MUST display a `cursor-text` cursor on hover to signal editability. The updated name MUST be immediately visible after submission. The 250ms click timer and double-click detection logic MUST be removed entirely.
#### FR-025 — ConfirmButton: Reusable component
The system MUST provide a reusable `ConfirmButton` component that wraps any icon button to add a two-step confirmation flow.
@@ -364,9 +362,7 @@ All domain events MUST be returned as plain data values from operations, not dis
- **ConfirmButton: component unmounts in confirm state**: The auto-revert timer MUST be cleaned up to prevent memory leaks or stale state updates.
- **ConfirmButton: two instances in confirm state simultaneously**: Each manages its own state independently.
- **ConfirmButton: combatant row re-renders while in confirm state**: Confirm state persists through re-renders as long as combatant identity is stable.
- **Name single-click vs double-click**: A single click on the combatant name opens the stat block panel; only a completed double-click enters inline edit mode. The system must disambiguate between the two gestures.
- **Touch edit affordance**: No hover-dependent affordance is shown on touch devices. Long-press is the touch equivalent for entering edit mode.
- **Long-press threshold**: The long-press duration should follow platform conventions (typically ~500ms). A short tap must not trigger edit mode.
- **Name click behavior is uniform**: A single click on any combatant's name enters inline edit mode immediately. There is no gesture disambiguation (no timer, no double-click detection). Stat block access is handled via the dedicated book icon on bestiary rows (see `specs/004-bestiary/spec.md`, FR-062).
---
@@ -401,4 +397,4 @@ All domain events MUST be returned as plain data values from operations, not dis
- Cross-tab synchronization is not required for the MVP baseline.
- The `ConfirmButton` 5-second timeout is a fixed value and is not configurable in the MVP baseline.
- The `Check` icon from the Lucide icon library is used for the `ConfirmButton` confirm state.
- The inline name-edit mechanism is activated by double-click or long-press (touch). A `cursor-text` cursor on hover signals editability. Single-clicking the name opens the stat block panel.
- The inline name-edit mechanism is activated by a single click on the name. A `cursor-text` cursor on hover signals editability. There is no double-click or long-press gesture; stat block access uses a dedicated book icon on bestiary rows.

View File

@@ -419,16 +419,15 @@ Acceptance scenarios:
3. **Given** any combatant row, **When** hovered and concentration is inactive, **Then** the Brain icon becomes visible.
4. **Given** the remove button appears on hover, **Then** no layout shift occurs — space is reserved.
**Story ROW-3 — Row Click Opens Stat Block (P1)**
As a DM, I want to click anywhere on a bestiary combatant row to open its stat block so I have a large click target and a cleaner row without a dedicated book icon.
**Story ROW-3 — Book Icon Opens Stat Block (P1)**
As a DM, I want a dedicated book icon on bestiary combatant rows so I can open the stat block with an explicit, discoverable control — while clicking the name always starts a rename.
Acceptance scenarios:
1. **Given** a combatant has a linked bestiary creature, **When** the user clicks the name text or empty row space, **Then** the stat block panel opens.
2. **Given** the user clicks an interactive element (initiative, HP, AC, condition icon, "+", "x", concentration), **Then** the stat block does NOT open — the element's own action fires.
3. **Given** a combatant does NOT have a linked creature, **When** the row is clicked, **Then** nothing happens.
4. **Given** viewing any bestiary combatant row, **Then** no BookOpen icon is visible.
5. **Given** a bestiary combatant row, **When** the user hovers over non-interactive areas, **Then** the cursor indicates clickability.
6. **Given** the stat block is already open for a creature, **When** the same row is clicked, **Then** the panel closes (toggle behavior).
1. **Given** a combatant has a linked bestiary creature, **When** the user views the row, **Then** a small BookOpen icon is visible next to the name.
2. **Given** a combatant does NOT have a linked creature, **When** the user views the row, **Then** no BookOpen icon is displayed.
3. **Given** a bestiary combatant row, **When** the user clicks the BookOpen icon, **Then** the stat block panel opens for that creature.
4. **Given** the user clicks the combatant's name, **Then** inline rename mode is entered — the stat block does NOT open.
5. **Given** the stat block is already open for a creature, **When** the user clicks its BookOpen icon again, **Then** the panel closes (toggle behavior).
### Requirements
@@ -436,10 +435,10 @@ Acceptance scenarios:
- **FR-081**: The "+" condition button MUST be hidden at rest and appear on row hover (or touch/focus on touch devices).
- **FR-082**: The remove (x) button MUST be hidden at rest and appear on row hover (or touch/focus on touch devices).
- **FR-083**: Layout space for the remove button MUST be reserved so appearing/disappearing does not cause layout shifts.
- **FR-084**: Clicking non-interactive areas of a bestiary combatant row MUST open the stat block panel.
- **FR-085**: Clicking interactive elements (initiative, HP, AC, conditions, "+", "x", concentration) MUST NOT trigger the stat block — only the element's own action.
- **FR-086**: The BookOpen icon MUST be removed from the combatant row.
- **FR-087**: Bestiary combatant rows MUST show a pointer cursor on hover over non-interactive areas.
- **FR-084**: Bestiary-linked combatant rows MUST display a BookOpen icon as the dedicated stat block trigger (see also `specs/004-bestiary/spec.md`, FR-062).
- **FR-085**: Clicking the combatant name MUST enter inline rename mode, not open the stat block.
- **FR-086**: Non-bestiary combatant rows MUST NOT display the BookOpen icon.
- **FR-087**: The BookOpen icon MUST have a tooltip ("View stat block") and `aria-label="View stat block"` for accessibility.
- **FR-088**: All existing interactions (condition add/remove, HP adjustment, AC editing, initiative editing/rolling, concentration toggle, combatant removal) MUST continue to work.
- **FR-089**: Browser scrollbars MUST be styled to match the dark UI theme (thin, dark-colored scrollbar thumbs).
- **FR-090**: Turn navigation (Previous/Next) MUST use StepBack/StepForward icons in outline button style with foreground-colored borders. Utility actions (d20/trash) MUST use ghost button style to create clear visual hierarchy.
@@ -452,8 +451,8 @@ Acceptance scenarios:
- When a combatant has so many conditions that they exceed the available inline space, they wrap within the name column; row height increases but width does not.
- The condition picker dropdown positions relative to the "+" button, flipping vertically if near the viewport edge.
- When the stat block panel is already open and the user clicks the same row again, the panel closes.
- Clicking the initiative area starts editing; it does not open the stat block.
- When the stat block panel is already open and the user clicks the same BookOpen icon again, the panel closes.
- Clicking the combatant name starts inline rename; it does not open the stat block.
- Tablet-width screens (>= 768 px / Tailwind `md`): popovers and inline edits MUST remain accessible and not overflow or clip.
---

View File

@@ -8,7 +8,7 @@
## Overview
The Bestiary feature provides creature search across a pre-indexed library of 3,312+ creatures from 102+ D&D sources, stat block display for full creature reference during combat, source data management via on-demand fetch or file upload, and a dual-panel UX with fold/unfold and pin capabilities. Creatures can be added individually or in batch from a search dropdown, with stats pre-filled from the index.
The Bestiary feature provides creature search across a pre-indexed library of 3,312+ creatures from 102+ D&D sources, stat block display for full creature reference during combat, source data management via on-demand fetch or file upload, and a dual-panel UX with collapse/expand and pin capabilities. Creatures can be added individually or in batch from a search dropdown, with stats pre-filled from the index.
The architecture uses a two-tier design: a lightweight search index (`data/bestiary/index.json`) shipped with the app containing mechanical facts for all creatures, and full stat block data loaded on-demand at the source level, normalized, and cached in IndexedDB.
@@ -84,7 +84,7 @@ When the search input has no bestiary matches (or fewer than 2 characters typed)
**US-D1 — View Full Stat Block in Side Panel (P2)**
As a DM, I want to see the full stat block of a creature displayed in a side panel so that I can reference its abilities, actions, and traits during combat without switching to another tool.
When a creature is selected from search results or when clicking a bestiary-linked combatant in the tracker, a stat block panel appears showing the creature's full information in the classic D&D stat block layout. Clicking a different combatant updates the panel to that creature's data.
When a creature is selected from search results or when clicking the book icon on a bestiary-linked combatant row, a stat block panel appears showing the creature's full information in the classic D&D stat block layout. Clicking the book icon on a different combatant updates the panel to that creature's data. The book icon is the only way to manually open a stat block from the tracker — clicking the combatant's name always enters inline rename mode (see `specs/001-combatant-management/spec.md`, FR-024).
**US-D2 — Preview Stat Block from Search Dropdown (P3)**
As a DM, I want to preview a creature's stat block directly from the search dropdown so I can review creature details before deciding to add them to the encounter.
@@ -103,18 +103,22 @@ As a DM using the app on different devices, I want the layout to adapt between s
- **FR-020**: On wide viewports (desktop), the layout MUST be side-by-side with the encounter tracker on the left and stat block on the right.
- **FR-021**: On narrow viewports (mobile), the stat block MUST appear as a dismissible drawer or slide-over.
- **FR-022**: The stat block panel MUST scroll independently of the encounter tracker.
- **FR-023**: When the user clicks a different bestiary-linked combatant in the tracker, the stat block panel MUST update to show that creature's data.
- **FR-023**: When the user clicks the book icon on a different bestiary-linked combatant row, the stat block panel MUST update to show that creature's data.
- **FR-024**: The existing search/view button MUST be repurposed to open the stat block for the currently focused/selected creature in the dropdown.
- **FR-062**: Bestiary-linked combatant rows MUST display a small book icon (Lucide `BookOpen`) as the dedicated stat block trigger. The icon MUST have a tooltip ("View stat block") and an `aria-label="View stat block"` for accessibility. The book icon is the only way to manually open a stat block from the tracker — clicking the combatant name always enters inline rename mode. Non-bestiary combatant rows MUST NOT display the book icon.
### Acceptance Scenarios
1. **Given** a creature is selected from the bestiary search, **When** the stat block panel opens, **Then** it displays: name, size, type, alignment, AC, HP (average and formula), speed, ability scores with modifiers, saving throws, skills, damage resistances/immunities, condition immunities, senses, languages, challenge rating, traits, actions, and legendary actions (if applicable).
2. **Given** the stat block panel is open on desktop (wide viewport), **Then** the layout is side-by-side: encounter tracker on the left, stat block panel on the right.
3. **Given** the stat block panel is open on mobile (narrow viewport), **Then** the stat block appears as a slide-over drawer that can be dismissed.
4. **Given** a stat block is displayed, **When** the user clicks a different bestiary-linked combatant in the tracker, **Then** the stat block panel updates to show that creature's data.
4. **Given** a stat block is displayed, **When** the user clicks the book icon on a different bestiary-linked combatant row, **Then** the stat block panel updates to show that creature's data.
5. **Given** a creature entry contains markup tags (e.g., spell references, dice notation), **Then** they render as plain text.
6. **Given** the dropdown is showing bestiary results, **When** the user clicks the stat block view button, **Then** the stat block panel opens for the currently focused/highlighted creature in the dropdown.
7. **Given** no creature is focused in the dropdown, **When** the user clicks the stat block view button, **Then** nothing happens (button is disabled or no-op).
8. **Given** a bestiary-linked combatant row is visible, **When** the user looks at the row, **Then** a small book icon is visible as the stat block trigger with a tooltip "View stat block".
9. **Given** a custom (non-bestiary) combatant row is visible, **When** the user looks at the row, **Then** no book icon is displayed.
10. **Given** a bestiary-linked combatant row is visible, **When** the user clicks the combatant's name, **Then** inline rename mode is entered — the stat block does NOT open.
### Edge Cases
@@ -144,7 +148,7 @@ While the bulk import is in progress, the user sees a text counter ("Loading sou
If the user closes the side panel while a bulk import is still in progress, a persistent toast notification appears at the bottom-center of the screen showing the same progress text and progress bar.
**US-M6 — Manage Cached Sources (P4)**
A DM wants to see which sources are cached, clear a specific source's cache, or clear all cached data. A management UI provides this visibility and control.
A DM wants to see which sources are cached, find a specific source, clear a specific source's cache, or clear all cached data. A management UI provides this visibility and control, including a filter input to quickly locate sources by name when many are cached.
### Requirements
@@ -170,7 +174,7 @@ A DM wants to see which sources are cached, clear a specific source's cache, or
- **FR-044**: The bulk import MUST run asynchronously and not block the rest of the app.
- **FR-045**: The user MUST explicitly provide/confirm the URL before any fetches occur — the app never auto-fetches content.
- **FR-046**: The "Load All" button MUST be disabled when the URL field is empty or while a bulk import is already in progress.
- **FR-047**: The app MUST provide a management UI showing cached sources with options to clear individual sources or all cached data.
- **FR-047**: The app MUST provide a management UI showing cached sources with a filter input for searching by display name and options to clear individual sources or all cached data.
- **FR-048**: The normalization adapter and tag-stripping utility MUST remain the canonical pipeline for all fetched and uploaded data.
- **FR-049**: The distributed app bundle MUST contain zero copyrighted prose content — only mechanical facts and creature names in the search index.
@@ -194,6 +198,7 @@ A DM wants to see which sources are cached, clear a specific source's cache, or
16. **Given** two sources have been cached, **When** the DM opens the source management UI, **Then** both sources are listed with their display names.
17. **Given** the source management UI is open, **When** the DM clears a single source, **Then** that source's data is removed; stat blocks for its creatures require re-fetching, while other cached sources remain.
18. **Given** the source management UI is open, **When** the DM clears all cached data, **Then** all source data is removed and all stat blocks require re-fetching.
19. **Given** many sources are cached, **When** the DM types a partial name in the filter input, **Then** only sources whose display name matches (case-insensitive) are shown.
### Edge Cases
@@ -210,58 +215,58 @@ A DM wants to see which sources are cached, clear a specific source's cache, or
---
## Panel UX (Fold, Pin, Second Panel)
## Panel UX (Collapse, Pin, Second Panel)
### User Stories
**US-P1 — Fold and Unfold Stat Block Panel (P1)**
**US-P1 — Collapse and Expand Stat Block Panel (P1)**
As a DM running an encounter, I want to collapse the stat block panel to a slim tab so I can temporarily reclaim screen space without losing my place, then quickly expand it again to reference creature stats.
The close button is replaced with a fold/unfold toggle. Folding slides the panel out to the right edge, leaving a slim vertical tab displaying the creature's name. Clicking the tab unfolds the panel, showing the same creature that was displayed before folding. No "Stat Block" heading text is shown in the panel header.
The close button is replaced with a collapse/expand toggle. Collapsing slides the panel out to the right edge, leaving a slim vertical tab displaying the creature's name. Clicking the tab expands the panel, showing the same creature that was displayed before collapsing. No "Stat Block" heading text is shown in the panel header.
**US-P2 — Pin Creature to Second Panel (P2)**
As a DM comparing creatures or referencing one creature while browsing others, I want to pin the current creature to a secondary panel on the left side of the screen so I can keep it visible while browsing different creatures in the right panel.
Clicking the pin button copies the current creature to a new left panel. The right panel remains active for browsing different creatures independently. The left panel has an unpin button that removes it.
**US-P3 — Fold Behavior with Pinned Panel (P3)**
As a DM with a creature pinned, I want to fold the right (browse) panel independently so I can focus on just the pinned creature, or fold both panels to see the full encounter list.
**US-P3 — Collapse Behavior with Pinned Panel (P3)**
As a DM with a creature pinned, I want to collapse the right (browse) panel independently so I can focus on just the pinned creature, or collapse both panels to see the full encounter list.
### Requirements
- **FR-050**: The system MUST replace the close button on the stat block panel with a fold/unfold toggle control.
- **FR-050**: The system MUST replace the close button on the stat block panel with a collapse/expand toggle control.
- **FR-051**: The system MUST remove the "Stat Block" heading text from the panel header.
- **FR-052**: When folded, the panel MUST collapse to a slim vertical tab anchored to the right edge of the screen displaying the creature's name.
- **FR-053**: Folding and unfolding MUST use a smooth CSS slide animation (~200ms ease-out).
- **FR-054**: The fold/unfold toggle MUST preserve the currently displayed creature — unfolding shows the same creature that was visible when folded.
- **FR-052**: When collapsed, the panel MUST reduce to a slim vertical tab anchored to the right edge of the screen displaying the creature's name.
- **FR-053**: Collapsing and expanding MUST use a smooth CSS slide animation (~200ms ease-out).
- **FR-054**: The collapse/expand toggle MUST preserve the currently displayed creature — expanding shows the same creature that was visible when collapsed.
- **FR-055**: The panel MUST include a pin button that copies the current creature to a new panel on the left side of the screen.
- **FR-056**: After pinning, the right panel MUST remain active for browsing different creatures independently.
- **FR-057**: The pinned (left) panel MUST include an unpin button that removes it when clicked.
- **FR-058**: The pin button MUST be hidden on viewports where dual panels would not fit (small screens / mobile).
- **FR-059**: The pin button MUST be hidden when the panel is showing a source fetch prompt (no creature data displayed yet).
- **FR-060**: On mobile viewports, the existing drawer/backdrop behavior MUST be preserved — fold/unfold replaces only the close button behavior for the desktop layout; the backdrop click still dismisses the panel.
- **FR-061**: Both the browse (right) and pinned (left) panels MUST have independent fold states.
- **FR-060**: On mobile viewports, the existing drawer/backdrop behavior MUST be preserved — collapse/expand replaces only the close button behavior for the desktop layout; the backdrop click still dismisses the panel.
- **FR-061**: Both the browse (right) and pinned (left) panels MUST have independent collapsed states.
### Acceptance Scenarios
1. **Given** the stat block panel is open showing a creature, **When** the user clicks the fold button, **Then** the panel slides out to the right edge and a slim vertical tab appears showing the creature's name.
2. **Given** the stat block panel is folded to a tab, **When** the user clicks the tab, **Then** the panel slides back in showing the same creature that was displayed before folding.
3. **Given** the stat block panel is open, **When** the user looks for a close button, **Then** no close button is present — only a fold toggle.
1. **Given** the stat block panel is open showing a creature, **When** the user clicks the collapse button, **Then** the panel slides out to the right edge and a slim vertical tab appears showing the creature's name.
2. **Given** the stat block panel is collapsed to a tab, **When** the user clicks the tab, **Then** the panel slides back in showing the same creature that was displayed before collapsing.
3. **Given** the stat block panel is open, **When** the user looks for a close button, **Then** no close button is present — only a collapse toggle.
4. **Given** the stat block panel is open, **When** the user looks at the panel header, **Then** no "Stat Block" heading text is visible.
5. **Given** the panel is folding or unfolding, **When** the animation plays, **Then** it completes with a smooth slide transition (~200ms ease-out).
5. **Given** the panel is collapsing or expanding, **When** the animation plays, **Then** it completes with a smooth slide transition (~200ms ease-out).
6. **Given** the stat block panel is showing a creature on a wide screen, **When** the user clicks the pin button, **Then** the current creature appears in a new panel on the left side of the screen.
7. **Given** a creature is pinned to the left panel, **When** the user clicks a different combatant in the encounter list, **Then** the right panel updates to show the new creature while the left panel continues showing the pinned creature.
7. **Given** a creature is pinned to the left panel, **When** the user clicks the book icon on a different bestiary combatant, **Then** the right panel updates to show the new creature while the left panel continues showing the pinned creature.
8. **Given** a creature is pinned to the left panel, **When** the user clicks the unpin button on the left panel, **Then** the left panel is removed and only the right panel remains.
9. **Given** the user is on a small screen or mobile viewport, **When** the stat block panel is displayed, **Then** the pin button is not visible.
10. **Given** both pinned (left) and browse (right) panels are open, **When** the user folds the right panel, **Then** the left pinned panel remains visible and the right panel collapses to a tab.
11. **Given** the right panel is folded and the left panel is pinned, **When** the user unfolds the right panel, **Then** it slides back showing the last browsed creature.
10. **Given** both pinned (left) and browse (right) panels are open, **When** the user collapses the right panel, **Then** the left pinned panel remains visible and the right panel reduces to a tab.
11. **Given** the right panel is collapsed and the left panel is pinned, **When** the user expands the right panel, **Then** it slides back showing the last browsed creature.
### Edge Cases
- User pins a creature and then that creature is removed from the encounter: the pinned panel continues displaying the creature's stat block (data is already loaded).
- User folds the panel and then the active combatant changes (auto-show logic on desktop): the panel stays folded but updates the selected creature internally; unfolding shows the current active combatant's stat block. The fold state is respected — advancing turns does not override a user-chosen fold.
- Active combatant changes while panel is open: if the new active combatant has a creature, the panel auto-updates to show that creature's stat block. If the new active combatant has no creature, the panel remains on the previous creature. If the panel is collapsed, it stays collapsed. If the panel is closed, it stays closed.
- Viewport resized from wide to narrow while a creature is pinned: the pinned (left) panel is hidden and the pin button disappears; resizing back to wide restores the pinned panel.
- User is in bulk import mode and tries to fold: the fold/unfold behavior applies to the bulk import panel identically.
- User is in bulk import mode and tries to collapse: the collapse/expand behavior applies to the bulk import panel identically.
- Panel showing a source fetch prompt: the pin button is hidden.
---
@@ -276,7 +281,7 @@ As a DM with a creature pinned, I want to fold the right (browse) panel independ
- **Queued Creature**: Transient UI-only state representing a bestiary creature selected for batch-add, containing the creature reference and a count (1+). Not persisted.
- **Bulk Import Operation**: Tracks total sources, completed count, failed count, and current status (idle / loading / complete / partial-failure).
- **Toast Notification**: Lightweight custom UI element at bottom-center of screen with text, optional progress bar, and optional dismiss button.
- **Panel State**: Represents whether a stat block panel is expanded, folded, or absent. The browse (right) and pinned (left) panels each have independent state.
- **Panel State**: Represents whether a stat block panel is expanded, collapsed, or absent. The browse (right) and pinned (left) panels each have independent state.
---
@@ -295,7 +300,7 @@ As a DM with a creature pinned, I want to fold the right (browse) panel independ
- **SC-011**: Users can load all bestiary sources with a single confirmation action; real-time progress is visible during the operation.
- **SC-012**: Already-cached sources are skipped during bulk import, reducing redundant data transfer on repeat imports.
- **SC-013**: The rest of the app remains fully interactive during the bulk import operation.
- **SC-014**: Users can fold the stat block panel in a single click and unfold it in a single click, with the transition completing in under 300ms.
- **SC-014**: Users can collapse the stat block panel in a single click and expand it in a single click, with the transition completing in under 300ms.
- **SC-015**: Users can pin a creature and browse a different creature's stat block simultaneously, without any additional navigation steps.
- **SC-016**: The pinned panel is never visible on screens narrower than the dual-panel breakpoint, ensuring mobile usability is not degraded.
- **SC-017**: All fold/unfold and pin/unpin interactions are discoverable through visible button controls without requiring documentation or tooltips.
- **SC-017**: All collapse/expand and pin/unpin interactions are discoverable through visible button controls without requiring documentation or tooltips.

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,
},
},
},