Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02096bcee6 | ||
|
|
c092192b0e | ||
|
|
4d1a7c6420 | ||
|
|
46b444caba | ||
|
|
e68145319f | ||
|
|
d64e1f5e4a | ||
|
|
ef0b755eec | ||
|
|
4be816d10f | ||
|
|
e531d82d1b | ||
|
|
5a262c66cd | ||
|
|
32b69f8df1 | ||
|
|
8efba288f7 | ||
|
|
c94c30e459 | ||
|
|
36768d3aa1 | ||
|
|
473f1eaefe | ||
|
|
971e0ded49 | ||
|
|
36dcfc5076 | ||
|
|
127ed01064 | ||
|
|
179c3658ad | ||
|
|
01f2bb3ff1 | ||
|
|
930301de71 | ||
|
|
aa806d4fb9 | ||
|
|
61bc274715 | ||
|
|
1932e837fb | ||
|
|
cce87318fb | ||
|
|
3ef2370a34 | ||
|
|
c75d148d1e | ||
|
|
63e233bd8d | ||
|
|
8c62ec28f2 | ||
|
|
72195e90f6 | ||
|
|
6ac8e67970 | ||
|
|
a4797d5b15 | ||
|
|
d48e39ced4 | ||
|
|
b7406c4b54 | ||
|
|
07cdd4867a |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,3 +11,4 @@ Thumbs.db
|
|||||||
.idea/
|
.idea/
|
||||||
coverage/
|
coverage/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
docs/agents/plans/
|
||||||
|
|||||||
27
.oxlintrc.json
Normal file
27
.oxlintrc.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
16
CLAUDE.md
16
CLAUDE.md
@@ -5,7 +5,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm check # Merge gate — must pass before every commit (audit + knip + biome + typecheck + test/coverage + jscpd)
|
pnpm check # Merge gate — must pass before every commit (audit + knip + biome + oxlint + typecheck + test/coverage + jscpd)
|
||||||
|
pnpm oxlint # Type-aware linting (oxlint — complements Biome)
|
||||||
pnpm knip # Unused code detection (Knip)
|
pnpm knip # Unused code detection (Knip)
|
||||||
pnpm test # Run all tests (Vitest)
|
pnpm test # Run all tests (Vitest)
|
||||||
pnpm test:watch # Tests in watch mode
|
pnpm test:watch # Tests in watch mode
|
||||||
@@ -58,12 +59,13 @@ docs/agents/ RPI skill artifacts (research reports, plans)
|
|||||||
- React 19, Vite 6, Tailwind CSS v4
|
- React 19, Vite 6, Tailwind CSS v4
|
||||||
- Lucide React (icons)
|
- Lucide React (icons)
|
||||||
- `idb` (IndexedDB wrapper for bestiary cache)
|
- `idb` (IndexedDB wrapper for bestiary cache)
|
||||||
- Biome 2.0 (formatting + linting), Knip (unused code), jscpd (copy-paste detection)
|
- Biome 2.4 (formatting + linting), oxlint (type-aware linting), Knip (unused code), jscpd (copy-paste detection)
|
||||||
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
|
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **Biome 2.0** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
|
- **Biome 2.4** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
|
||||||
|
- **oxlint** for type-aware linting that Biome can't do (unnecessary type assertions, deprecated APIs, `replaceAll` preference, `String.raw`). Configured in `.oxlintrc.json`.
|
||||||
- **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports when required by the repo's ESM settings (e.g., `./types.js`).
|
- **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports when required by the repo's ESM settings (e.g., `./types.js`).
|
||||||
- **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical.
|
- **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical.
|
||||||
- **Domain events** are plain data objects with a `type` discriminant — no classes.
|
- **Domain events** are plain data objects with a `type` discriminant — no classes.
|
||||||
@@ -71,6 +73,14 @@ docs/agents/ RPI skill artifacts (research reports, plans)
|
|||||||
- **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`.
|
- **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`.
|
||||||
- **Quality gates** are enforced at pre-commit via Lefthook's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process.
|
- **Quality gates** are enforced at pre-commit via Lefthook's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process.
|
||||||
|
|
||||||
|
## Self-Review Checklist
|
||||||
|
|
||||||
|
Before finishing a change, consider:
|
||||||
|
- Is this the simplest approach that solves the current problem?
|
||||||
|
- Is there duplication that hurts readability? (But don't abstract prematurely.)
|
||||||
|
- Are errors handled correctly and communicated sensibly to the user?
|
||||||
|
- Does the UI follow modern patterns and feel intuitive to interact with?
|
||||||
|
|
||||||
## Speckit Workflow
|
## Speckit Workflow
|
||||||
|
|
||||||
Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Specs are **living documents** that describe features, not individual changes.
|
Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Specs are **living documents** that describe features, not individual changes.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import {
|
|||||||
rollAllInitiativeUseCase,
|
rollAllInitiativeUseCase,
|
||||||
rollInitiativeUseCase,
|
rollInitiativeUseCase,
|
||||||
} from "@initiative/application";
|
} from "@initiative/application";
|
||||||
import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type Creature,
|
||||||
|
type CreatureId,
|
||||||
|
isDomainError,
|
||||||
|
} from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -11,10 +16,12 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { ActionBar } from "./components/action-bar";
|
import { ActionBar } from "./components/action-bar";
|
||||||
|
import { BulkImportToasts } from "./components/bulk-import-toasts";
|
||||||
import { CombatantRow } from "./components/combatant-row";
|
import { CombatantRow } from "./components/combatant-row";
|
||||||
import { CreatePlayerModal } from "./components/create-player-modal";
|
import {
|
||||||
import { PlayerManagement } from "./components/player-management";
|
PlayerCharacterSection,
|
||||||
import { SourceManager } from "./components/source-manager";
|
type PlayerCharacterSectionHandle,
|
||||||
|
} from "./components/player-character-section";
|
||||||
import { StatBlockPanel } from "./components/stat-block-panel";
|
import { StatBlockPanel } from "./components/stat-block-panel";
|
||||||
import { Toast } from "./components/toast";
|
import { Toast } from "./components/toast";
|
||||||
import { TurnNavigation } from "./components/turn-navigation";
|
import { TurnNavigation } from "./components/turn-navigation";
|
||||||
@@ -22,6 +29,7 @@ import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
|
|||||||
import { useBulkImport } from "./hooks/use-bulk-import";
|
import { useBulkImport } from "./hooks/use-bulk-import";
|
||||||
import { useEncounter } from "./hooks/use-encounter";
|
import { useEncounter } from "./hooks/use-encounter";
|
||||||
import { usePlayerCharacters } from "./hooks/use-player-characters";
|
import { usePlayerCharacters } from "./hooks/use-player-characters";
|
||||||
|
import { useSidePanelState } from "./hooks/use-side-panel-state";
|
||||||
|
|
||||||
function rollDice(): number {
|
function rollDice(): number {
|
||||||
return Math.floor(Math.random() * 20) + 1;
|
return Math.floor(Math.random() * 20) + 1;
|
||||||
@@ -47,11 +55,10 @@ function useActionBarAnimation(combatantCount: number) {
|
|||||||
const empty = combatantCount === 0;
|
const empty = combatantCount === 0;
|
||||||
const risingClass = rising ? " animate-rise-to-center" : "";
|
const risingClass = rising ? " animate-rise-to-center" : "";
|
||||||
const settlingClass = settling ? " animate-settle-to-bottom" : "";
|
const settlingClass = settling ? " animate-settle-to-bottom" : "";
|
||||||
const topBarClass = settling
|
const exitingClass = topBarExiting
|
||||||
? " animate-slide-down-in"
|
|
||||||
: topBarExiting
|
|
||||||
? " absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
|
? " 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;
|
const showTopBar = !empty || topBarExiting;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -68,6 +75,9 @@ function useActionBarAnimation(combatantCount: number) {
|
|||||||
export function App() {
|
export function App() {
|
||||||
const {
|
const {
|
||||||
encounter,
|
encounter,
|
||||||
|
isEmpty,
|
||||||
|
hasCreatureCombatants,
|
||||||
|
canRollAllInitiative,
|
||||||
advanceTurn,
|
advanceTurn,
|
||||||
retreatTurn,
|
retreatTurn,
|
||||||
addCombatant,
|
addCombatant,
|
||||||
@@ -92,12 +102,6 @@ export function App() {
|
|||||||
deleteCharacter: deletePlayerCharacter,
|
deleteCharacter: deletePlayerCharacter,
|
||||||
} = usePlayerCharacters();
|
} = usePlayerCharacters();
|
||||||
|
|
||||||
const [createPlayerOpen, setCreatePlayerOpen] = useState(false);
|
|
||||||
const [managementOpen, setManagementOpen] = useState(false);
|
|
||||||
const [editingPlayer, setEditingPlayer] = useState<
|
|
||||||
(typeof playerCharacters)[number] | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
search,
|
search,
|
||||||
getCreature,
|
getCreature,
|
||||||
@@ -109,32 +113,16 @@ export function App() {
|
|||||||
} = useBestiary();
|
} = useBestiary();
|
||||||
|
|
||||||
const bulkImport = useBulkImport();
|
const bulkImport = useBulkImport();
|
||||||
|
const sidePanel = useSidePanelState();
|
||||||
|
|
||||||
const [selectedCreatureId, setSelectedCreatureId] =
|
const [rollSkippedCount, setRollSkippedCount] = useState(0);
|
||||||
useState<CreatureId | null>(null);
|
|
||||||
const [bulkImportMode, setBulkImportMode] = useState(false);
|
|
||||||
const [sourceManagerOpen, setSourceManagerOpen] = useState(false);
|
|
||||||
const [isRightPanelFolded, setIsRightPanelFolded] = useState(false);
|
|
||||||
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [isWideDesktop, setIsWideDesktop] = useState(
|
|
||||||
() => window.matchMedia("(min-width: 1280px)").matches,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const selectedCreature: Creature | null = sidePanel.selectedCreatureId
|
||||||
const mq = window.matchMedia("(min-width: 1280px)");
|
? (getCreature(sidePanel.selectedCreatureId) ?? null)
|
||||||
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
|
|
||||||
mq.addEventListener("change", handler);
|
|
||||||
return () => mq.removeEventListener("change", handler);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const selectedCreature: Creature | null = selectedCreatureId
|
|
||||||
? (getCreature(selectedCreatureId) ?? null)
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const pinnedCreature: Creature | null = pinnedCreatureId
|
const pinnedCreature: Creature | null = sidePanel.pinnedCreatureId
|
||||||
? (getCreature(pinnedCreatureId) ?? null)
|
? (getCreature(sidePanel.pinnedCreatureId) ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const handleAddFromBestiary = useCallback(
|
const handleAddFromBestiary = useCallback(
|
||||||
@@ -144,10 +132,12 @@ export function App() {
|
|||||||
[addFromBestiary],
|
[addFromBestiary],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCombatantStatBlock = useCallback((creatureId: string) => {
|
const handleCombatantStatBlock = useCallback(
|
||||||
setSelectedCreatureId(creatureId as CreatureId);
|
(creatureId: string) => {
|
||||||
setIsRightPanelFolded(false);
|
sidePanel.showCreature(creatureId as CreatureId);
|
||||||
}, []);
|
},
|
||||||
|
[sidePanel.showCreature],
|
||||||
|
);
|
||||||
|
|
||||||
const handleRollInitiative = useCallback(
|
const handleRollInitiative = useCallback(
|
||||||
(id: CombatantId) => {
|
(id: CombatantId) => {
|
||||||
@@ -157,23 +147,23 @@ export function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleRollAllInitiative = useCallback(() => {
|
const handleRollAllInitiative = useCallback(() => {
|
||||||
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
const result = rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
||||||
|
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
||||||
|
setRollSkippedCount(result.skippedNoSource);
|
||||||
|
}
|
||||||
}, [makeStore, getCreature]);
|
}, [makeStore, getCreature]);
|
||||||
|
|
||||||
const handleViewStatBlock = useCallback((result: SearchResult) => {
|
const handleViewStatBlock = useCallback(
|
||||||
|
(result: SearchResult) => {
|
||||||
const slug = result.name
|
const slug = result.name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||||
.replace(/(^-|-$)/g, "");
|
.replaceAll(/(^-|-$)/g, "");
|
||||||
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
||||||
setSelectedCreatureId(cId);
|
sidePanel.showCreature(cId);
|
||||||
setIsRightPanelFolded(false);
|
},
|
||||||
}, []);
|
[sidePanel.showCreature],
|
||||||
|
);
|
||||||
const handleBulkImport = useCallback(() => {
|
|
||||||
setBulkImportMode(true);
|
|
||||||
setSelectedCreatureId(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleStartBulkImport = useCallback(
|
const handleStartBulkImport = useCallback(
|
||||||
(baseUrl: string) => {
|
(baseUrl: string) => {
|
||||||
@@ -188,32 +178,12 @@ export function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleBulkImportDone = useCallback(() => {
|
const handleBulkImportDone = useCallback(() => {
|
||||||
setBulkImportMode(false);
|
sidePanel.dismissPanel();
|
||||||
bulkImport.reset();
|
bulkImport.reset();
|
||||||
}, [bulkImport.reset]);
|
}, [sidePanel.dismissPanel, bulkImport.reset]);
|
||||||
|
|
||||||
const handleDismissBrowsePanel = useCallback(() => {
|
|
||||||
setSelectedCreatureId(null);
|
|
||||||
setBulkImportMode(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleToggleFold = useCallback(() => {
|
|
||||||
setIsRightPanelFolded((f) => !f);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePin = useCallback(() => {
|
|
||||||
if (selectedCreatureId) {
|
|
||||||
setPinnedCreatureId((prev) =>
|
|
||||||
prev === selectedCreatureId ? null : selectedCreatureId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [selectedCreatureId]);
|
|
||||||
|
|
||||||
const handleUnpin = useCallback(() => {
|
|
||||||
setPinnedCreatureId(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
|
||||||
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
||||||
|
|
||||||
// Auto-scroll to the active combatant when the turn changes
|
// Auto-scroll to the active combatant when the turn changes
|
||||||
@@ -223,30 +193,12 @@ export function App() {
|
|||||||
block: "nearest",
|
block: "nearest",
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
}, [encounter.activeIndex]);
|
}, []);
|
||||||
|
|
||||||
// Auto-show stat block for the active combatant when turn changes,
|
|
||||||
// but only when the viewport is wide enough to show it alongside the tracker.
|
|
||||||
// Only react to activeIndex changes — not combatant reordering (e.g. Roll All).
|
|
||||||
const prevActiveIndexRef = useRef(encounter.activeIndex);
|
|
||||||
useEffect(() => {
|
|
||||||
if (prevActiveIndexRef.current === encounter.activeIndex) return;
|
|
||||||
prevActiveIndexRef.current = encounter.activeIndex;
|
|
||||||
if (!window.matchMedia("(min-width: 1024px)").matches) return;
|
|
||||||
const active = encounter.combatants[encounter.activeIndex];
|
|
||||||
if (!active?.creatureId || !isLoaded) return;
|
|
||||||
setSelectedCreatureId(active.creatureId as CreatureId);
|
|
||||||
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
|
|
||||||
|
|
||||||
const isEmpty = encounter.combatants.length === 0;
|
|
||||||
const showRollAllInitiative = encounter.combatants.some(
|
|
||||||
(c) => c.creatureId != null && c.initiative == null,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-dvh flex-col">
|
||||||
<div className="relative mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0">
|
<div className="relative mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-3 px-4">
|
||||||
{actionBarAnim.showTopBar && (
|
{!!actionBarAnim.showTopBar && (
|
||||||
<div
|
<div
|
||||||
className={`shrink-0 pt-8${actionBarAnim.topBarClass}`}
|
className={`shrink-0 pt-8${actionBarAnim.topBarClass}`}
|
||||||
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
||||||
@@ -262,7 +214,7 @@ export function App() {
|
|||||||
|
|
||||||
{isEmpty ? (
|
{isEmpty ? (
|
||||||
/* Empty state — ActionBar centered */
|
/* 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
|
<div
|
||||||
className={`w-full${actionBarAnim.risingClass}`}
|
className={`w-full${actionBarAnim.risingClass}`}
|
||||||
onAnimationEnd={actionBarAnim.onRiseEnd}
|
onAnimationEnd={actionBarAnim.onRiseEnd}
|
||||||
@@ -273,29 +225,26 @@ export function App() {
|
|||||||
bestiarySearch={search}
|
bestiarySearch={search}
|
||||||
bestiaryLoaded={isLoaded}
|
bestiaryLoaded={isLoaded}
|
||||||
onViewStatBlock={handleViewStatBlock}
|
onViewStatBlock={handleViewStatBlock}
|
||||||
onBulkImport={handleBulkImport}
|
onBulkImport={sidePanel.showBulkImport}
|
||||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
bulkImportDisabled={bulkImport.state.status === "loading"}
|
||||||
inputRef={actionBarInputRef}
|
inputRef={actionBarInputRef}
|
||||||
playerCharacters={playerCharacters}
|
playerCharacters={playerCharacters}
|
||||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||||
onManagePlayers={() => setManagementOpen(true)}
|
onManagePlayers={() =>
|
||||||
|
playerCharacterRef.current?.openManagement()
|
||||||
|
}
|
||||||
onRollAllInitiative={handleRollAllInitiative}
|
onRollAllInitiative={handleRollAllInitiative}
|
||||||
showRollAllInitiative={showRollAllInitiative}
|
showRollAllInitiative={hasCreatureCombatants}
|
||||||
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
|
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||||
|
onOpenSourceManager={sidePanel.showSourceManager}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{sourceManagerOpen && (
|
|
||||||
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
|
|
||||||
<SourceManager onCacheCleared={refreshCache} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Scrollable area — combatant list */}
|
{/* 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">
|
<div className="flex flex-col px-2 py-2">
|
||||||
{encounter.combatants.map((c, i) => (
|
{encounter.combatants.map((c, i) => (
|
||||||
<CombatantRow
|
<CombatantRow
|
||||||
@@ -335,15 +284,18 @@ export function App() {
|
|||||||
bestiarySearch={search}
|
bestiarySearch={search}
|
||||||
bestiaryLoaded={isLoaded}
|
bestiaryLoaded={isLoaded}
|
||||||
onViewStatBlock={handleViewStatBlock}
|
onViewStatBlock={handleViewStatBlock}
|
||||||
onBulkImport={handleBulkImport}
|
onBulkImport={sidePanel.showBulkImport}
|
||||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
bulkImportDisabled={bulkImport.state.status === "loading"}
|
||||||
inputRef={actionBarInputRef}
|
inputRef={actionBarInputRef}
|
||||||
playerCharacters={playerCharacters}
|
playerCharacters={playerCharacters}
|
||||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||||
onManagePlayers={() => setManagementOpen(true)}
|
onManagePlayers={() =>
|
||||||
|
playerCharacterRef.current?.openManagement()
|
||||||
|
}
|
||||||
onRollAllInitiative={handleRollAllInitiative}
|
onRollAllInitiative={handleRollAllInitiative}
|
||||||
showRollAllInitiative={showRollAllInitiative}
|
showRollAllInitiative={hasCreatureCombatants}
|
||||||
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
|
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||||
|
onOpenSourceManager={sidePanel.showSourceManager}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -351,19 +303,19 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pinned Stat Block Panel (left) */}
|
{/* Pinned Stat Block Panel (left) */}
|
||||||
{pinnedCreatureId && isWideDesktop && (
|
{!!sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
|
||||||
<StatBlockPanel
|
<StatBlockPanel
|
||||||
creatureId={pinnedCreatureId}
|
creatureId={sidePanel.pinnedCreatureId}
|
||||||
creature={pinnedCreature}
|
creature={pinnedCreature}
|
||||||
isSourceCached={isSourceCached}
|
isSourceCached={isSourceCached}
|
||||||
fetchAndCacheSource={fetchAndCacheSource}
|
fetchAndCacheSource={fetchAndCacheSource}
|
||||||
uploadAndCacheSource={uploadAndCacheSource}
|
uploadAndCacheSource={uploadAndCacheSource}
|
||||||
refreshCache={refreshCache}
|
refreshCache={refreshCache}
|
||||||
panelRole="pinned"
|
panelRole="pinned"
|
||||||
isFolded={false}
|
isCollapsed={false}
|
||||||
onToggleFold={() => {}}
|
onToggleCollapse={() => {}}
|
||||||
onPin={() => {}}
|
onPin={() => {}}
|
||||||
onUnpin={handleUnpin}
|
onUnpin={sidePanel.unpin}
|
||||||
showPinButton={false}
|
showPinButton={false}
|
||||||
side="left"
|
side="left"
|
||||||
onDismiss={() => {}}
|
onDismiss={() => {}}
|
||||||
@@ -372,90 +324,47 @@ export function App() {
|
|||||||
|
|
||||||
{/* Browse Stat Block Panel (right) */}
|
{/* Browse Stat Block Panel (right) */}
|
||||||
<StatBlockPanel
|
<StatBlockPanel
|
||||||
creatureId={selectedCreatureId}
|
creatureId={sidePanel.selectedCreatureId}
|
||||||
creature={selectedCreature}
|
creature={selectedCreature}
|
||||||
isSourceCached={isSourceCached}
|
isSourceCached={isSourceCached}
|
||||||
fetchAndCacheSource={fetchAndCacheSource}
|
fetchAndCacheSource={fetchAndCacheSource}
|
||||||
uploadAndCacheSource={uploadAndCacheSource}
|
uploadAndCacheSource={uploadAndCacheSource}
|
||||||
refreshCache={refreshCache}
|
refreshCache={refreshCache}
|
||||||
panelRole="browse"
|
panelRole="browse"
|
||||||
isFolded={isRightPanelFolded}
|
isCollapsed={sidePanel.isRightPanelCollapsed}
|
||||||
onToggleFold={handleToggleFold}
|
onToggleCollapse={sidePanel.toggleCollapse}
|
||||||
onPin={handlePin}
|
onPin={sidePanel.togglePin}
|
||||||
onUnpin={() => {}}
|
onUnpin={() => {}}
|
||||||
showPinButton={isWideDesktop && !!selectedCreature}
|
showPinButton={sidePanel.isWideDesktop && !!selectedCreature}
|
||||||
side="right"
|
side="right"
|
||||||
onDismiss={handleDismissBrowsePanel}
|
onDismiss={sidePanel.dismissPanel}
|
||||||
bulkImportMode={bulkImportMode}
|
bulkImportMode={sidePanel.bulkImportMode}
|
||||||
bulkImportState={bulkImport.state}
|
bulkImportState={bulkImport.state}
|
||||||
onStartBulkImport={handleStartBulkImport}
|
onStartBulkImport={handleStartBulkImport}
|
||||||
onBulkImportDone={handleBulkImportDone}
|
onBulkImportDone={handleBulkImportDone}
|
||||||
|
sourceManagerMode={sidePanel.sourceManagerMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Toast for bulk import progress when panel is closed */}
|
<BulkImportToasts
|
||||||
{bulkImport.state.status === "loading" && !bulkImportMode && (
|
state={bulkImport.state}
|
||||||
<Toast
|
visible={!sidePanel.bulkImportMode || sidePanel.isRightPanelCollapsed}
|
||||||
message={`Loading sources... ${bulkImport.state.completed + bulkImport.state.failed}/${bulkImport.state.total}`}
|
onReset={bulkImport.reset}
|
||||||
progress={
|
|
||||||
bulkImport.state.total > 0
|
|
||||||
? (bulkImport.state.completed + bulkImport.state.failed) /
|
|
||||||
bulkImport.state.total
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
onDismiss={() => {}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{bulkImport.state.status === "complete" && !bulkImportMode && (
|
{rollSkippedCount > 0 && (
|
||||||
<Toast
|
<Toast
|
||||||
message="All sources loaded"
|
message={`${rollSkippedCount} skipped — bestiary source not loaded`}
|
||||||
onDismiss={bulkImport.reset}
|
onDismiss={() => setRollSkippedCount(0)}
|
||||||
autoDismissMs={3000}
|
autoDismissMs={4000}
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{bulkImport.state.status === "partial-failure" && !bulkImportMode && (
|
|
||||||
<Toast
|
|
||||||
message={`Loaded ${bulkImport.state.completed}/${bulkImport.state.total} sources (${bulkImport.state.failed} failed)`}
|
|
||||||
onDismiss={bulkImport.reset}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CreatePlayerModal
|
<PlayerCharacterSection
|
||||||
open={createPlayerOpen}
|
ref={playerCharacterRef}
|
||||||
onClose={() => {
|
|
||||||
setCreatePlayerOpen(false);
|
|
||||||
setEditingPlayer(undefined);
|
|
||||||
}}
|
|
||||||
onSave={(name, ac, maxHp, color, icon) => {
|
|
||||||
if (editingPlayer) {
|
|
||||||
editPlayerCharacter?.(editingPlayer.id, {
|
|
||||||
name,
|
|
||||||
ac,
|
|
||||||
maxHp,
|
|
||||||
color,
|
|
||||||
icon,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
createPlayerCharacter(name, ac, maxHp, color, icon);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
playerCharacter={editingPlayer}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PlayerManagement
|
|
||||||
open={managementOpen}
|
|
||||||
onClose={() => setManagementOpen(false)}
|
|
||||||
characters={playerCharacters}
|
characters={playerCharacters}
|
||||||
onEdit={(pc) => {
|
onCreateCharacter={createPlayerCharacter}
|
||||||
setEditingPlayer(pc);
|
onEditCharacter={editPlayerCharacter}
|
||||||
setCreatePlayerOpen(true);
|
onDeleteCharacter={deletePlayerCharacter}
|
||||||
setManagementOpen(false);
|
|
||||||
}}
|
|
||||||
onDelete={(id) => deletePlayerCharacter?.(id)}
|
|
||||||
onCreate={() => {
|
|
||||||
setEditingPlayer(undefined);
|
|
||||||
setCreatePlayerOpen(true);
|
|
||||||
setManagementOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
163
apps/web/src/__tests__/app-integration.test.tsx
Normal file
163
apps/web/src/__tests__/app-integration.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -200,6 +200,7 @@ describe("ConfirmButton", () => {
|
|||||||
const parentHandler = vi.fn();
|
const parentHandler = vi.fn();
|
||||||
render(
|
render(
|
||||||
// biome-ignore lint/a11y/noStaticElementInteractions: test wrapper
|
// biome-ignore lint/a11y/noStaticElementInteractions: test wrapper
|
||||||
|
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: test wrapper
|
||||||
<div onKeyDown={parentHandler}>
|
<div onKeyDown={parentHandler}>
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
icon={<XIcon />}
|
icon={<XIcon />}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { StatBlockPanel } from "../components/stat-block-panel";
|
import { StatBlockPanel } from "../components/stat-block-panel";
|
||||||
|
|
||||||
|
const CLOSE_REGEX = /close/i;
|
||||||
|
const COLLAPSE_REGEX = /collapse/i;
|
||||||
|
|
||||||
const CREATURE_ID = "srd:goblin" as CreatureId;
|
const CREATURE_ID = "srd:goblin" as CreatureId;
|
||||||
const CREATURE: Creature = {
|
const CREATURE: Creature = {
|
||||||
id: CREATURE_ID,
|
id: CREATURE_ID,
|
||||||
@@ -26,7 +29,7 @@ const CREATURE: Creature = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function mockMatchMedia(matches: boolean) {
|
function mockMatchMedia(matches: boolean) {
|
||||||
Object.defineProperty(window, "matchMedia", {
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
writable: true,
|
writable: true,
|
||||||
value: vi.fn().mockImplementation((query: string) => ({
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
matches,
|
matches,
|
||||||
@@ -45,8 +48,8 @@ interface PanelProps {
|
|||||||
creatureId?: CreatureId | null;
|
creatureId?: CreatureId | null;
|
||||||
creature?: Creature | null;
|
creature?: Creature | null;
|
||||||
panelRole?: "browse" | "pinned";
|
panelRole?: "browse" | "pinned";
|
||||||
isFolded?: boolean;
|
isCollapsed?: boolean;
|
||||||
onToggleFold?: () => void;
|
onToggleCollapse?: () => void;
|
||||||
onPin?: () => void;
|
onPin?: () => void;
|
||||||
onUnpin?: () => void;
|
onUnpin?: () => void;
|
||||||
showPinButton?: boolean;
|
showPinButton?: boolean;
|
||||||
@@ -64,8 +67,8 @@ function renderPanel(overrides: PanelProps = {}) {
|
|||||||
uploadAndCacheSource: vi.fn(),
|
uploadAndCacheSource: vi.fn(),
|
||||||
refreshCache: vi.fn(),
|
refreshCache: vi.fn(),
|
||||||
panelRole: "browse" as const,
|
panelRole: "browse" as const,
|
||||||
isFolded: false,
|
isCollapsed: false,
|
||||||
onToggleFold: vi.fn(),
|
onToggleCollapse: vi.fn(),
|
||||||
onPin: vi.fn(),
|
onPin: vi.fn(),
|
||||||
onUnpin: vi.fn(),
|
onUnpin: vi.fn(),
|
||||||
showPinButton: false,
|
showPinButton: false,
|
||||||
@@ -78,21 +81,21 @@ function renderPanel(overrides: PanelProps = {}) {
|
|||||||
return props;
|
return props;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Stat Block Panel Fold/Unfold and Pin", () => {
|
describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockMatchMedia(true); // desktop by default
|
mockMatchMedia(true); // desktop by default
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
describe("US1: Fold and Unfold", () => {
|
describe("US1: Collapse and Expand", () => {
|
||||||
it("shows fold button instead of close button on desktop", () => {
|
it("shows collapse button instead of close button on desktop", () => {
|
||||||
renderPanel();
|
renderPanel();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Fold stat block panel" }),
|
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("button", { name: /close/i }),
|
screen.queryByRole("button", { name: CLOSE_REGEX }),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,42 +104,42 @@ describe("Stat Block Panel Fold/Unfold and Pin", () => {
|
|||||||
expect(screen.queryByText("Stat Block")).not.toBeInTheDocument();
|
expect(screen.queryByText("Stat Block")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders folded tab with creature name when isFolded is true", () => {
|
it("renders collapsed tab with creature name when isCollapsed is true", () => {
|
||||||
renderPanel({ isFolded: true });
|
renderPanel({ isCollapsed: true });
|
||||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Unfold stat block panel" }),
|
screen.getByRole("button", { name: "Expand stat block panel" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onToggleFold when fold button is clicked", () => {
|
it("calls onToggleCollapse when collapse button is clicked", () => {
|
||||||
const props = renderPanel();
|
const props = renderPanel();
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", { name: "Fold stat block panel" }),
|
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
||||||
);
|
);
|
||||||
expect(props.onToggleFold).toHaveBeenCalledTimes(1);
|
expect(props.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onToggleFold when folded tab is clicked", () => {
|
it("calls onToggleCollapse when collapsed tab is clicked", () => {
|
||||||
const props = renderPanel({ isFolded: true });
|
const props = renderPanel({ isCollapsed: true });
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", { name: "Unfold stat block panel" }),
|
screen.getByRole("button", { name: "Expand stat block panel" }),
|
||||||
);
|
);
|
||||||
expect(props.onToggleFold).toHaveBeenCalledTimes(1);
|
expect(props.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies translate-x class when folded (right side)", () => {
|
it("applies translate-x class when collapsed (right side)", () => {
|
||||||
renderPanel({ isFolded: true, side: "right" });
|
renderPanel({ isCollapsed: true, side: "right" });
|
||||||
const panel = screen
|
const panel = screen
|
||||||
.getByRole("button", { name: "Unfold stat block panel" })
|
.getByRole("button", { name: "Expand stat block panel" })
|
||||||
.closest("div");
|
.closest("div");
|
||||||
expect(panel?.className).toContain("translate-x-[calc(100%-40px)]");
|
expect(panel?.className).toContain("translate-x-[calc(100%-40px)]");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies translate-x-0 when expanded", () => {
|
it("applies translate-x-0 when expanded", () => {
|
||||||
renderPanel({ isFolded: false });
|
renderPanel({ isCollapsed: false });
|
||||||
const foldBtn = screen.getByRole("button", {
|
const foldBtn = screen.getByRole("button", {
|
||||||
name: "Fold stat block panel",
|
name: "Collapse stat block panel",
|
||||||
});
|
});
|
||||||
const panel = foldBtn.closest("div.fixed") as HTMLElement;
|
const panel = foldBtn.closest("div.fixed") as HTMLElement;
|
||||||
expect(panel?.className).toContain("translate-x-0");
|
expect(panel?.className).toContain("translate-x-0");
|
||||||
@@ -148,12 +151,12 @@ describe("Stat Block Panel Fold/Unfold and Pin", () => {
|
|||||||
mockMatchMedia(false); // mobile
|
mockMatchMedia(false); // mobile
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows fold button instead of X close button on mobile drawer", () => {
|
it("shows collapse button instead of X close button on mobile drawer", () => {
|
||||||
renderPanel();
|
renderPanel();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Fold stat block panel" }),
|
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
// No X close icon button — only backdrop dismiss and fold toggle
|
// No X close icon button — only backdrop dismiss and collapse toggle
|
||||||
const buttons = screen.getAllByRole("button");
|
const buttons = screen.getAllByRole("button");
|
||||||
const buttonLabels = buttons.map((b) => b.getAttribute("aria-label"));
|
const buttonLabels = buttons.map((b) => b.getAttribute("aria-label"));
|
||||||
expect(buttonLabels).not.toContain("Close");
|
expect(buttonLabels).not.toContain("Close");
|
||||||
@@ -175,8 +178,8 @@ describe("Stat Block Panel Fold/Unfold and Pin", () => {
|
|||||||
uploadAndCacheSource={vi.fn()}
|
uploadAndCacheSource={vi.fn()}
|
||||||
refreshCache={vi.fn()}
|
refreshCache={vi.fn()}
|
||||||
panelRole="pinned"
|
panelRole="pinned"
|
||||||
isFolded={false}
|
isCollapsed={false}
|
||||||
onToggleFold={vi.fn()}
|
onToggleCollapse={vi.fn()}
|
||||||
onPin={vi.fn()}
|
onPin={vi.fn()}
|
||||||
onUnpin={vi.fn()}
|
onUnpin={vi.fn()}
|
||||||
showPinButton={false}
|
showPinButton={false}
|
||||||
@@ -235,7 +238,7 @@ describe("Stat Block Panel Fold/Unfold and Pin", () => {
|
|||||||
it("positions browse panel on the right side", () => {
|
it("positions browse panel on the right side", () => {
|
||||||
renderPanel({ panelRole: "browse", side: "right" });
|
renderPanel({ panelRole: "browse", side: "right" });
|
||||||
const foldBtn = screen.getByRole("button", {
|
const foldBtn = screen.getByRole("button", {
|
||||||
name: "Fold stat block panel",
|
name: "Collapse stat block panel",
|
||||||
});
|
});
|
||||||
const panel = foldBtn.closest("div.fixed") as HTMLElement;
|
const panel = foldBtn.closest("div.fixed") as HTMLElement;
|
||||||
expect(panel?.className).toContain("right-0");
|
expect(panel?.className).toContain("right-0");
|
||||||
@@ -243,16 +246,16 @@ describe("Stat Block Panel Fold/Unfold and Pin", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("US3: Fold independence with pinned panel", () => {
|
describe("US3: Collapse independence with pinned panel", () => {
|
||||||
it("pinned panel has no fold button", () => {
|
it("pinned panel has no collapse button", () => {
|
||||||
renderPanel({ panelRole: "pinned", side: "left" });
|
renderPanel({ panelRole: "pinned", side: "left" });
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("button", { name: /fold/i }),
|
screen.queryByRole("button", { name: COLLAPSE_REGEX }),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("pinned panel is always expanded (no translate offset)", () => {
|
it("pinned panel is always expanded (no translate offset)", () => {
|
||||||
renderPanel({ panelRole: "pinned", side: "left", isFolded: false });
|
renderPanel({ panelRole: "pinned", side: "left", isCollapsed: false });
|
||||||
const unpinBtn = screen.getByRole("button", {
|
const unpinBtn = screen.getByRole("button", {
|
||||||
name: "Unpin creature",
|
name: "Unpin creature",
|
||||||
});
|
});
|
||||||
@@ -30,11 +30,11 @@ describe("stripTags", () => {
|
|||||||
expect(stripTags("{@hit 5}")).toBe("+5");
|
expect(stripTags("{@hit 5}")).toBe("+5");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips {@h} to Hit: ", () => {
|
it("strips {@h} to Hit:", () => {
|
||||||
expect(stripTags("{@h}")).toBe("Hit: ");
|
expect(stripTags("{@h}")).toBe("Hit: ");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips {@hom} to Hit or Miss: ", () => {
|
it("strips {@hom} to Hit or Miss:", () => {
|
||||||
expect(stripTags("{@hom}")).toBe("Hit or Miss: ");
|
expect(stripTags("{@hom}")).toBe("Hit or Miss: ");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type {
|
|||||||
import { creatureId, proficiencyBonus } from "@initiative/domain";
|
import { creatureId, proficiencyBonus } from "@initiative/domain";
|
||||||
import { stripTags } from "./strip-tags.js";
|
import { stripTags } from "./strip-tags.js";
|
||||||
|
|
||||||
|
const LEADING_DIGITS_REGEX = /^(\d+)/;
|
||||||
|
|
||||||
// --- Raw 5etools types (minimal, for parsing) ---
|
// --- Raw 5etools types (minimal, for parsing) ---
|
||||||
|
|
||||||
interface RawMonster {
|
interface RawMonster {
|
||||||
@@ -49,6 +51,7 @@ interface RawMonster {
|
|||||||
legendaryHeader?: string[];
|
legendaryHeader?: string[];
|
||||||
spellcasting?: RawSpellcasting[];
|
spellcasting?: RawSpellcasting[];
|
||||||
initiative?: { proficiency?: number };
|
initiative?: { proficiency?: number };
|
||||||
|
_copy?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawEntry {
|
interface RawEntry {
|
||||||
@@ -168,7 +171,7 @@ function extractAc(ac: RawMonster["ac"]): {
|
|||||||
}
|
}
|
||||||
if ("special" in first) {
|
if ("special" in first) {
|
||||||
// Variable AC (e.g. spell summons) — parse leading number if possible
|
// Variable AC (e.g. spell summons) — parse leading number if possible
|
||||||
const match = first.special.match(/^(\d+)/);
|
const match = LEADING_DIGITS_REGEX.exec(first.special);
|
||||||
return {
|
return {
|
||||||
value: match ? Number(match[1]) : 0,
|
value: match ? Number(match[1]) : 0,
|
||||||
source: first.special,
|
source: first.special,
|
||||||
@@ -371,8 +374,8 @@ function extractCr(cr: string | { cr: string } | undefined): string {
|
|||||||
function makeCreatureId(source: string, name: string): CreatureId {
|
function makeCreatureId(source: string, name: string): CreatureId {
|
||||||
const slug = name
|
const slug = name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||||
.replace(/(^-|-$)/g, "");
|
.replaceAll(/(^-|-$)/g, "");
|
||||||
return creatureId(`${source.toLowerCase()}:${slug}`);
|
return creatureId(`${source.toLowerCase()}:${slug}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,8 +386,7 @@ export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
|
|||||||
// Filter out _copy entries (reference another source's monster) and
|
// Filter out _copy entries (reference another source's monster) and
|
||||||
// monsters missing required fields (ac, hp, size, type)
|
// monsters missing required fields (ac, hp, size, type)
|
||||||
const monsters = raw.monster.filter((m) => {
|
const monsters = raw.monster.filter((m) => {
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: raw JSON may have _copy field
|
if (m._copy) return false;
|
||||||
if ((m as any)._copy) return false;
|
|
||||||
return (
|
return (
|
||||||
Array.isArray(m.ac) &&
|
Array.isArray(m.ac) &&
|
||||||
m.ac.length > 0 &&
|
m.ac.length > 0 &&
|
||||||
|
|||||||
@@ -25,55 +25,58 @@ export function stripTags(text: string): string {
|
|||||||
let result = text;
|
let result = text;
|
||||||
|
|
||||||
// {@h} → "Hit: "
|
// {@h} → "Hit: "
|
||||||
result = result.replace(/\{@h\}/g, "Hit: ");
|
result = result.replaceAll("{@h}", "Hit: ");
|
||||||
|
|
||||||
// {@hom} → "Hit or Miss: "
|
// {@hom} → "Hit or Miss: "
|
||||||
result = result.replace(/\{@hom\}/g, "Hit or Miss: ");
|
result = result.replaceAll("{@hom}", "Hit or Miss: ");
|
||||||
|
|
||||||
// {@actTrigger} → "Trigger:"
|
// {@actTrigger} → "Trigger:"
|
||||||
result = result.replace(/\{@actTrigger\}/g, "Trigger:");
|
result = result.replaceAll("{@actTrigger}", "Trigger:");
|
||||||
|
|
||||||
// {@actResponse} → "Response:"
|
// {@actResponse} → "Response:"
|
||||||
result = result.replace(/\{@actResponse\}/g, "Response:");
|
result = result.replaceAll("{@actResponse}", "Response:");
|
||||||
|
|
||||||
// {@actSaveSuccess} → "Success:"
|
// {@actSaveSuccess} → "Success:"
|
||||||
result = result.replace(/\{@actSaveSuccess\}/g, "Success:");
|
result = result.replaceAll("{@actSaveSuccess}", "Success:");
|
||||||
|
|
||||||
// {@actSaveSuccessOrFail} → handled below as parameterized
|
// {@actSaveSuccessOrFail} → handled below as parameterized
|
||||||
|
|
||||||
// {@recharge 5} → "(Recharge 5-6)", {@recharge} → "(Recharge 6)"
|
// {@recharge 5} → "(Recharge 5-6)", {@recharge} → "(Recharge 6)"
|
||||||
result = result.replace(/\{@recharge\s+(\d)\}/g, "(Recharge $1-6)");
|
result = result.replaceAll(/\{@recharge\s+(\d)\}/g, "(Recharge $1-6)");
|
||||||
result = result.replace(/\{@recharge\}/g, "(Recharge 6)");
|
result = result.replaceAll("{@recharge}", "(Recharge 6)");
|
||||||
|
|
||||||
// {@dc N} → "DC N"
|
// {@dc N} → "DC N"
|
||||||
result = result.replace(/\{@dc\s+(\d+)\}/g, "DC $1");
|
result = result.replaceAll(/\{@dc\s+(\d+)\}/g, "DC $1");
|
||||||
|
|
||||||
// {@hit N} → "+N"
|
// {@hit N} → "+N"
|
||||||
result = result.replace(/\{@hit\s+(\d+)\}/g, "+$1");
|
result = result.replaceAll(/\{@hit\s+(\d+)\}/g, "+$1");
|
||||||
|
|
||||||
// {@atkr type} → mapped attack roll text
|
// {@atkr type} → mapped attack roll text
|
||||||
result = result.replace(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
|
result = result.replaceAll(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
|
||||||
return ATKR_MAP[type.trim()] ?? `Attack Roll:`;
|
return ATKR_MAP[type.trim()] ?? "Attack Roll:";
|
||||||
});
|
});
|
||||||
|
|
||||||
// {@actSave ability} → "Ability saving throw"
|
// {@actSave ability} → "Ability saving throw"
|
||||||
result = result.replace(/\{@actSave\s+([^}]+)\}/g, (_, ability: string) => {
|
result = result.replaceAll(
|
||||||
|
/\{@actSave\s+([^}]+)\}/g,
|
||||||
|
(_, ability: string) => {
|
||||||
const name = ABILITY_MAP[ability.trim().toLowerCase()];
|
const name = ABILITY_MAP[ability.trim().toLowerCase()];
|
||||||
return name ? `${name} saving throw` : `${ability} saving throw`;
|
return name ? `${name} saving throw` : `${ability} saving throw`;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// {@actSaveFail} → "Failure:" or {@actSaveFail N} → "Failure by N or More:"
|
// {@actSaveFail} → "Failure:" or {@actSaveFail N} → "Failure by N or More:"
|
||||||
result = result.replace(
|
result = result.replaceAll(
|
||||||
/\{@actSaveFail\s+(\d+)\}/g,
|
/\{@actSaveFail\s+(\d+)\}/g,
|
||||||
"Failure by $1 or More:",
|
"Failure by $1 or More:",
|
||||||
);
|
);
|
||||||
result = result.replace(/\{@actSaveFail\}/g, "Failure:");
|
result = result.replaceAll("{@actSaveFail}", "Failure:");
|
||||||
|
|
||||||
// {@actSaveSuccessOrFail} → keep as-is label
|
// {@actSaveSuccessOrFail} → keep as-is label
|
||||||
result = result.replace(/\{@actSaveSuccessOrFail\}/g, "Success or Failure:");
|
result = result.replaceAll("{@actSaveSuccessOrFail}", "Success or Failure:");
|
||||||
|
|
||||||
// {@actSaveFailBy N} → "Failure by N or More:"
|
// {@actSaveFailBy N} → "Failure by N or More:"
|
||||||
result = result.replace(
|
result = result.replaceAll(
|
||||||
/\{@actSaveFailBy\s+(\d+)\}/g,
|
/\{@actSaveFailBy\s+(\d+)\}/g,
|
||||||
"Failure by $1 or More:",
|
"Failure by $1 or More:",
|
||||||
);
|
);
|
||||||
@@ -81,7 +84,7 @@ export function stripTags(text: string): string {
|
|||||||
// Generic tags: {@tag Display|Source|...} → Display (first segment before |)
|
// Generic tags: {@tag Display|Source|...} → Display (first segment before |)
|
||||||
// Covers: spell, condition, damage, dice, variantrule, action, skill,
|
// Covers: spell, condition, damage, dice, variantrule, action, skill,
|
||||||
// creature, hazard, status, plus any unknown tags
|
// creature, hazard, status, plus any unknown tags
|
||||||
result = result.replace(
|
result = result.replaceAll(
|
||||||
/\{@(\w+)\s+([^}]+)\}/g,
|
/\{@(\w+)\s+([^}]+)\}/g,
|
||||||
(_, tag: string, content: string) => {
|
(_, tag: string, content: string) => {
|
||||||
// For tags with Display|Source format, extract first segment
|
// For tags with Display|Source format, extract first segment
|
||||||
|
|||||||
88
apps/web/src/components/__tests__/action-bar.test.tsx
Normal file
88
apps/web/src/components/__tests__/action-bar.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
164
apps/web/src/components/__tests__/combatant-row.test.tsx
Normal file
164
apps/web/src/components/__tests__/combatant-row.test.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { combatantId } from "@initiative/domain";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { CombatantRow } from "../combatant-row";
|
||||||
|
import { PLAYER_COLOR_HEX } from "../player-icon-map";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
onRename: vi.fn(),
|
||||||
|
onSetInitiative: vi.fn(),
|
||||||
|
onRemove: vi.fn(),
|
||||||
|
onSetHp: vi.fn(),
|
||||||
|
onAdjustHp: vi.fn(),
|
||||||
|
onSetAc: vi.fn(),
|
||||||
|
onToggleCondition: vi.fn(),
|
||||||
|
onToggleConcentration: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderRow(
|
||||||
|
overrides: Partial<{
|
||||||
|
combatant: Parameters<typeof CombatantRow>[0]["combatant"];
|
||||||
|
isActive: boolean;
|
||||||
|
onRollInitiative: (id: ReturnType<typeof combatantId>) => void;
|
||||||
|
onRemove: (id: ReturnType<typeof combatantId>) => void;
|
||||||
|
onShowStatBlock: () => void;
|
||||||
|
}> = {},
|
||||||
|
) {
|
||||||
|
const combatant = overrides.combatant ?? {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
initiative: 15,
|
||||||
|
maxHp: 10,
|
||||||
|
currentHp: 10,
|
||||||
|
ac: 13,
|
||||||
|
};
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
combatant,
|
||||||
|
isActive: overrides.isActive ?? false,
|
||||||
|
onRollInitiative: overrides.onRollInitiative,
|
||||||
|
onShowStatBlock: overrides.onShowStatBlock,
|
||||||
|
onRemove: overrides.onRemove ?? defaultProps.onRemove,
|
||||||
|
};
|
||||||
|
return render(<CombatantRow {...props} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CombatantRow", () => {
|
||||||
|
it("renders combatant name", () => {
|
||||||
|
renderRow();
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders initiative value", () => {
|
||||||
|
renderRow();
|
||||||
|
expect(screen.getByText("15")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders current HP", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 10,
|
||||||
|
currentHp: 7,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(screen.getByText("7")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("active combatant gets active border styling", () => {
|
||||||
|
const { container } = renderRow({ isActive: true });
|
||||||
|
const row = container.firstElementChild;
|
||||||
|
expect(row?.className).toContain("border-l-accent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unconscious combatant (currentHp === 0) gets dimmed styling", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 10,
|
||||||
|
currentHp: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// The name area should have opacity-50
|
||||||
|
const nameEl = screen.getByText("Goblin");
|
||||||
|
const nameContainer = nameEl.closest(".opacity-50");
|
||||||
|
expect(nameContainer).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows '--' for current HP when no maxHp is set", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(screen.getByLabelText("No HP set")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows concentration icon when isConcentrating is true", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
isConcentrating: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const concButton = screen.getByRole("button", {
|
||||||
|
name: "Toggle concentration",
|
||||||
|
});
|
||||||
|
expect(concButton.className).toContain("text-purple-400");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows player character icon and color when set", () => {
|
||||||
|
const { container } = renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Aragorn",
|
||||||
|
color: "red",
|
||||||
|
icon: "sword",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// The icon should be rendered with the player color
|
||||||
|
const svgIcon = container.querySelector("svg[style]");
|
||||||
|
expect(svgIcon).not.toBeNull();
|
||||||
|
expect(svgIcon).toHaveStyle({ color: PLAYER_COLOR_HEX.red });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remove button calls onRemove after confirmation", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onRemove = vi.fn();
|
||||||
|
renderRow({ onRemove });
|
||||||
|
const removeBtn = screen.getByRole("button", {
|
||||||
|
name: "Remove combatant",
|
||||||
|
});
|
||||||
|
// First click enters confirm state
|
||||||
|
await user.click(removeBtn);
|
||||||
|
// Second click confirms
|
||||||
|
const confirmBtn = screen.getByRole("button", {
|
||||||
|
name: "Confirm remove combatant",
|
||||||
|
});
|
||||||
|
await user.click(confirmBtn);
|
||||||
|
expect(onRemove).toHaveBeenCalledWith(combatantId("1"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows d20 roll button when initiative is undefined and onRollInitiative is provided", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
},
|
||||||
|
onRollInitiative: vi.fn(),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Roll initiative" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
63
apps/web/src/components/__tests__/condition-picker.test.tsx
Normal file
63
apps/web/src/components/__tests__/condition-picker.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
115
apps/web/src/components/__tests__/hp-adjust-popover.test.tsx
Normal file
115
apps/web/src/components/__tests__/hp-adjust-popover.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
127
apps/web/src/components/__tests__/source-manager.test.tsx
Normal file
127
apps/web/src/components/__tests__/source-manager.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,7 +12,7 @@ export function AcShield({ value, onClick, className }: AcShieldProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex items-center justify-center text-sm tabular-nums text-muted-foreground transition-colors hover:text-hover-neutral",
|
"relative inline-flex items-center justify-center text-muted-foreground text-sm tabular-nums transition-colors hover:text-hover-neutral",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={{ width: 28, height: 32 }}
|
style={{ width: 28, height: 32 }}
|
||||||
@@ -29,8 +29,8 @@ export function AcShield({ value, onClick, className }: AcShieldProps) {
|
|||||||
>
|
>
|
||||||
<path d="M14 1.5 L2.5 6.5 L2.5 15 Q2.5 25 14 30.5 Q25.5 25 25.5 15 L25.5 6.5 Z" />
|
<path d="M14 1.5 L2.5 6.5 L2.5 15 Q2.5 25 14 30.5 Q25.5 25 25.5 15 L25.5 6.5 Z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="relative text-xs font-medium leading-none">
|
<span className="relative font-medium text-xs leading-none">
|
||||||
{value !== undefined ? value : "\u2014"}
|
{value == null ? "\u2014" : String(value)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PlayerCharacter, PlayerIcon } from "@initiative/domain";
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { type FormEvent, type RefObject, useState } from "react";
|
import React, { type RefObject, useDeferredValue, useState } from "react";
|
||||||
import type { SearchResult } from "../hooks/use-bestiary.js";
|
import type { SearchResult } from "../hooks/use-bestiary.js";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
import { D20Icon } from "./d20-icon.js";
|
import { D20Icon } from "./d20-icon.js";
|
||||||
@@ -40,6 +40,7 @@ interface ActionBarProps {
|
|||||||
onManagePlayers?: () => void;
|
onManagePlayers?: () => void;
|
||||||
onRollAllInitiative?: () => void;
|
onRollAllInitiative?: () => void;
|
||||||
showRollAllInitiative?: boolean;
|
showRollAllInitiative?: boolean;
|
||||||
|
rollAllInitiativeDisabled?: boolean;
|
||||||
onOpenSourceManager?: () => void;
|
onOpenSourceManager?: () => void;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
}
|
}
|
||||||
@@ -60,60 +61,63 @@ function AddModeSuggestions({
|
|||||||
onSetQueued,
|
onSetQueued,
|
||||||
onConfirmQueued,
|
onConfirmQueued,
|
||||||
onAddFromPlayerCharacter,
|
onAddFromPlayerCharacter,
|
||||||
}: {
|
onClear,
|
||||||
|
}: Readonly<{
|
||||||
nameInput: string;
|
nameInput: string;
|
||||||
suggestions: SearchResult[];
|
suggestions: SearchResult[];
|
||||||
pcMatches: PlayerCharacter[];
|
pcMatches: PlayerCharacter[];
|
||||||
suggestionIndex: number;
|
suggestionIndex: number;
|
||||||
queued: QueuedCreature | null;
|
queued: QueuedCreature | null;
|
||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
|
onClear: () => void;
|
||||||
onClickSuggestion: (result: SearchResult) => void;
|
onClickSuggestion: (result: SearchResult) => void;
|
||||||
onSetSuggestionIndex: (i: number) => void;
|
onSetSuggestionIndex: (i: number) => void;
|
||||||
onSetQueued: (q: QueuedCreature | null) => void;
|
onSetQueued: (q: QueuedCreature | null) => void;
|
||||||
onConfirmQueued: () => void;
|
onConfirmQueued: () => void;
|
||||||
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||||
}) {
|
}>) {
|
||||||
return (
|
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="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
|
||||||
<button
|
<button
|
||||||
type="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()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
<span className="flex-1">Add "{nameInput}" as custom</span>
|
<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
|
Esc
|
||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
<div className="max-h-48 overflow-y-auto py-1">
|
<div className="max-h-48 overflow-y-auto py-1">
|
||||||
{pcMatches.length > 0 && (
|
{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
|
Players
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
{pcMatches.map((pc) => {
|
{pcMatches.map((pc) => {
|
||||||
const PcIcon = PLAYER_ICON_MAP[pc.icon as PlayerIcon];
|
const PcIcon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
|
||||||
const pcColor =
|
const pcColor = pc.color
|
||||||
PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX];
|
? PLAYER_COLOR_HEX[pc.color]
|
||||||
|
: undefined;
|
||||||
return (
|
return (
|
||||||
<li key={pc.id}>
|
<li key={pc.id}>
|
||||||
<button
|
<button
|
||||||
type="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()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onAddFromPlayerCharacter?.(pc);
|
onAddFromPlayerCharacter?.(pc);
|
||||||
onDismiss();
|
onClear();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{PcIcon && (
|
{!!PcIcon && (
|
||||||
<PcIcon size={14} style={{ color: pcColor }} />
|
<PcIcon size={14} style={{ color: pcColor }} />
|
||||||
)}
|
)}
|
||||||
<span className="flex-1 truncate">{pc.name}</span>
|
<span className="flex-1 truncate">{pc.name}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-muted-foreground text-xs">
|
||||||
Player
|
Player
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -133,19 +137,18 @@ function AddModeSuggestions({
|
|||||||
<li key={key}>
|
<li key={key}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${(() => {
|
||||||
isQueued
|
if (isQueued) return "bg-accent/30 text-foreground";
|
||||||
? "bg-accent/30 text-foreground"
|
if (i === suggestionIndex)
|
||||||
: i === suggestionIndex
|
return "bg-accent/20 text-foreground";
|
||||||
? "bg-accent/20 text-foreground"
|
return "text-foreground hover:bg-hover-neutral-bg";
|
||||||
: "text-foreground hover:bg-hover-neutral-bg"
|
})()}`}
|
||||||
}`}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => onClickSuggestion(result)}
|
onClick={() => onClickSuggestion(result)}
|
||||||
onMouseEnter={() => onSetSuggestionIndex(i)}
|
onMouseEnter={() => onSetSuggestionIndex(i)}
|
||||||
>
|
>
|
||||||
<span>{result.name}</span>
|
<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 ? (
|
{isQueued ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -235,7 +238,7 @@ function buildOverflowItems(opts: {
|
|||||||
if (opts.bestiaryLoaded && opts.onBulkImport) {
|
if (opts.bestiaryLoaded && opts.onBulkImport) {
|
||||||
items.push({
|
items.push({
|
||||||
icon: <Import className="h-4 w-4" />,
|
icon: <Import className="h-4 w-4" />,
|
||||||
label: "Bulk Import",
|
label: "Import All Sources",
|
||||||
onClick: opts.onBulkImport,
|
onClick: opts.onBulkImport,
|
||||||
disabled: opts.bulkImportDisabled,
|
disabled: opts.bulkImportDisabled,
|
||||||
});
|
});
|
||||||
@@ -257,12 +260,15 @@ export function ActionBar({
|
|||||||
onManagePlayers,
|
onManagePlayers,
|
||||||
onRollAllInitiative,
|
onRollAllInitiative,
|
||||||
showRollAllInitiative,
|
showRollAllInitiative,
|
||||||
|
rollAllInitiativeDisabled,
|
||||||
onOpenSourceManager,
|
onOpenSourceManager,
|
||||||
autoFocus,
|
autoFocus,
|
||||||
}: ActionBarProps) {
|
}: Readonly<ActionBarProps>) {
|
||||||
const [nameInput, setNameInput] = useState("");
|
const [nameInput, setNameInput] = useState("");
|
||||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||||
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||||
|
const deferredSuggestions = useDeferredValue(suggestions);
|
||||||
|
const deferredPcMatches = useDeferredValue(pcMatches);
|
||||||
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
||||||
const [queued, setQueued] = useState<QueuedCreature | null>(null);
|
const [queued, setQueued] = useState<QueuedCreature | null>(null);
|
||||||
const [customInit, setCustomInit] = useState("");
|
const [customInit, setCustomInit] = useState("");
|
||||||
@@ -284,6 +290,13 @@ export function ActionBar({
|
|||||||
setSuggestionIndex(-1);
|
setSuggestionIndex(-1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dismissSuggestions = () => {
|
||||||
|
setSuggestions([]);
|
||||||
|
setPcMatches([]);
|
||||||
|
setQueued(null);
|
||||||
|
setSuggestionIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
const confirmQueued = () => {
|
const confirmQueued = () => {
|
||||||
if (!queued) return;
|
if (!queued) return;
|
||||||
for (let i = 0; i < queued.count; i++) {
|
for (let i = 0; i < queued.count; i++) {
|
||||||
@@ -298,7 +311,7 @@ export function ActionBar({
|
|||||||
return Number.isNaN(n) ? undefined : n;
|
return Number.isNaN(n) ? undefined : n;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAdd = (e: FormEvent) => {
|
const handleAdd = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (browseMode) return;
|
if (browseMode) return;
|
||||||
if (queued) {
|
if (queued) {
|
||||||
@@ -380,7 +393,8 @@ export function ActionBar({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasSuggestions = suggestions.length > 0 || pcMatches.length > 0;
|
const hasSuggestions =
|
||||||
|
deferredSuggestions.length > 0 || deferredPcMatches.length > 0;
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (!hasSuggestions) return;
|
if (!hasSuggestions) return;
|
||||||
@@ -395,7 +409,7 @@ export function ActionBar({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleEnter();
|
handleEnter();
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
clearInput();
|
dismissSuggestions();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -460,12 +474,12 @@ export function ActionBar({
|
|||||||
className="pr-8"
|
className="pr-8"
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
/>
|
/>
|
||||||
{bestiaryLoaded && onViewStatBlock && (
|
{bestiaryLoaded && !!onViewStatBlock && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className={cn(
|
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",
|
browseMode && "text-accent",
|
||||||
)}
|
)}
|
||||||
onClick={toggleBrowseMode}
|
onClick={toggleBrowseMode}
|
||||||
@@ -481,10 +495,10 @@ export function ActionBar({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{browseMode && suggestions.length > 0 && (
|
{browseMode && deferredSuggestions.length > 0 && (
|
||||||
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
|
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
|
||||||
<ul className="max-h-48 overflow-y-auto py-1">
|
<ul className="max-h-48 overflow-y-auto py-1">
|
||||||
{suggestions.map((result, i) => (
|
{deferredSuggestions.map((result, i) => (
|
||||||
<li key={creatureKey(result)}>
|
<li key={creatureKey(result)}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -498,7 +512,7 @@ export function ActionBar({
|
|||||||
onMouseEnter={() => setSuggestionIndex(i)}
|
onMouseEnter={() => setSuggestionIndex(i)}
|
||||||
>
|
>
|
||||||
<span>{result.name}</span>
|
<span>{result.name}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-muted-foreground text-xs">
|
||||||
{result.sourceDisplayName}
|
{result.sourceDisplayName}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -510,11 +524,12 @@ export function ActionBar({
|
|||||||
{!browseMode && hasSuggestions && (
|
{!browseMode && hasSuggestions && (
|
||||||
<AddModeSuggestions
|
<AddModeSuggestions
|
||||||
nameInput={nameInput}
|
nameInput={nameInput}
|
||||||
suggestions={suggestions}
|
suggestions={deferredSuggestions}
|
||||||
pcMatches={pcMatches}
|
pcMatches={deferredPcMatches}
|
||||||
suggestionIndex={suggestionIndex}
|
suggestionIndex={suggestionIndex}
|
||||||
queued={queued}
|
queued={queued}
|
||||||
onDismiss={clearInput}
|
onDismiss={dismissSuggestions}
|
||||||
|
onClear={clearInput}
|
||||||
onClickSuggestion={handleClickSuggestion}
|
onClickSuggestion={handleClickSuggestion}
|
||||||
onSetSuggestionIndex={setSuggestionIndex}
|
onSetSuggestionIndex={setSuggestionIndex}
|
||||||
onSetQueued={setQueued}
|
onSetQueued={setQueued}
|
||||||
@@ -555,13 +570,14 @@ export function ActionBar({
|
|||||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||||
<Button type="submit">Add</Button>
|
<Button type="submit">Add</Button>
|
||||||
)}
|
)}
|
||||||
{showRollAllInitiative && onRollAllInitiative && (
|
{showRollAllInitiative && !!onRollAllInitiative && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-muted-foreground hover:text-hover-action"
|
className="text-muted-foreground hover:text-hover-action"
|
||||||
onClick={onRollAllInitiative}
|
onClick={onRollAllInitiative}
|
||||||
|
disabled={rollAllInitiativeDisabled}
|
||||||
title="Roll all initiative"
|
title="Roll all initiative"
|
||||||
aria-label="Roll all initiative"
|
aria-label="Roll all initiative"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js";
|
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js";
|
||||||
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
@@ -18,14 +18,15 @@ export function BulkImportPrompt({
|
|||||||
importState,
|
importState,
|
||||||
onStartImport,
|
onStartImport,
|
||||||
onDone,
|
onDone,
|
||||||
}: BulkImportPromptProps) {
|
}: Readonly<BulkImportPromptProps>) {
|
||||||
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
|
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
|
||||||
|
const baseUrlId = useId();
|
||||||
const totalSources = getAllSourceCodes().length;
|
const totalSources = getAllSourceCodes().length;
|
||||||
|
|
||||||
if (importState.status === "complete") {
|
if (importState.status === "complete") {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-sm text-green-400">
|
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-green-400 text-sm">
|
||||||
All sources loaded
|
All sources loaded
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={onDone}>Done</Button>
|
<Button onClick={onDone}>Done</Button>
|
||||||
@@ -54,7 +55,7 @@ export function BulkImportPrompt({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Loading sources... {processed}/{importState.total}
|
Loading sources... {processed}/{importState.total}
|
||||||
</div>
|
</div>
|
||||||
@@ -74,24 +75,20 @@ export function BulkImportPrompt({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-foreground">
|
<h3 className="font-semibold text-foreground text-sm">
|
||||||
Bulk Import Sources
|
Import All Sources
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-muted-foreground text-xs">
|
||||||
Load stat block data for all {totalSources} sources at once. This will
|
Load stat block data for all {totalSources} sources at once.
|
||||||
download approximately 12.5 MB of data.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label
|
<label htmlFor={baseUrlId} className="text-muted-foreground text-xs">
|
||||||
htmlFor="bulk-base-url"
|
|
||||||
className="text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
Base URL
|
Base URL
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="bulk-base-url"
|
id={baseUrlId}
|
||||||
type="url"
|
type="url"
|
||||||
value={baseUrl}
|
value={baseUrl}
|
||||||
onChange={(e) => setBaseUrl(e.target.value)}
|
onChange={(e) => setBaseUrl(e.target.value)}
|
||||||
|
|||||||
49
apps/web/src/components/bulk-import-toasts.tsx
Normal file
49
apps/web/src/components/bulk-import-toasts.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||||
|
import { Toast } from "./toast.js";
|
||||||
|
|
||||||
|
interface BulkImportToastsProps {
|
||||||
|
state: BulkImportState;
|
||||||
|
visible: boolean;
|
||||||
|
onReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BulkImportToasts({
|
||||||
|
state,
|
||||||
|
visible,
|
||||||
|
onReset,
|
||||||
|
}: Readonly<BulkImportToastsProps>) {
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
if (state.status === "loading") {
|
||||||
|
return (
|
||||||
|
<Toast
|
||||||
|
message={`Loading sources... ${state.completed + state.failed}/${state.total}`}
|
||||||
|
progress={
|
||||||
|
state.total > 0 ? (state.completed + state.failed) / state.total : 0
|
||||||
|
}
|
||||||
|
onDismiss={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === "complete") {
|
||||||
|
return (
|
||||||
|
<Toast
|
||||||
|
message="All sources loaded"
|
||||||
|
onDismiss={onReset}
|
||||||
|
autoDismissMs={3000}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === "partial-failure") {
|
||||||
|
return (
|
||||||
|
<Toast
|
||||||
|
message={`Loaded ${state.completed}/${state.total} sources (${state.failed} failed)`}
|
||||||
|
onDismiss={onReset}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -9,18 +9,18 @@ interface ColorPaletteProps {
|
|||||||
|
|
||||||
const COLORS = [...VALID_PLAYER_COLORS] as string[];
|
const COLORS = [...VALID_PLAYER_COLORS] as string[];
|
||||||
|
|
||||||
export function ColorPalette({ value, onChange }: ColorPaletteProps) {
|
export function ColorPalette({ value, onChange }: Readonly<ColorPaletteProps>) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{COLORS.map((color) => (
|
{COLORS.map((color) => (
|
||||||
<button
|
<button
|
||||||
key={color}
|
key={color}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(color)}
|
onClick={() => onChange(value === color ? "" : color)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-8 rounded-full transition-all",
|
"h-8 w-8 rounded-full transition-all",
|
||||||
value === color
|
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",
|
: "hover:scale-110",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
deriveHpStatus,
|
deriveHpStatus,
|
||||||
type PlayerIcon,
|
type PlayerIcon,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { Brain, X } from "lucide-react";
|
import { BookOpen, Brain, X } from "lucide-react";
|
||||||
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { AcShield } from "./ac-shield";
|
import { AcShield } from "./ac-shield";
|
||||||
@@ -48,21 +48,16 @@ function EditableName({
|
|||||||
name,
|
name,
|
||||||
combatantId,
|
combatantId,
|
||||||
onRename,
|
onRename,
|
||||||
onShowStatBlock,
|
|
||||||
color,
|
color,
|
||||||
}: {
|
}: Readonly<{
|
||||||
name: string;
|
name: string;
|
||||||
combatantId: CombatantId;
|
combatantId: CombatantId;
|
||||||
onRename: (id: CombatantId, newName: string) => void;
|
onRename: (id: CombatantId, newName: string) => void;
|
||||||
onShowStatBlock?: () => void;
|
|
||||||
color?: string;
|
color?: string;
|
||||||
}) {
|
}>) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(name);
|
const [draft, setDraft] = useState(name);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
||||||
const longPressTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
||||||
const longPressTriggeredRef = useRef(false);
|
|
||||||
|
|
||||||
const commit = useCallback(() => {
|
const commit = useCallback(() => {
|
||||||
const trimmed = draft.trim();
|
const trimmed = draft.trim();
|
||||||
@@ -78,46 +73,6 @@ function EditableName({
|
|||||||
requestAnimationFrame(() => inputRef.current?.select());
|
requestAnimationFrame(() => inputRef.current?.select());
|
||||||
}, [name]);
|
}, [name]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
clearTimeout(clickTimerRef.current);
|
|
||||||
clearTimeout(longPressTimerRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleClick = useCallback(
|
|
||||||
(e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (longPressTriggeredRef.current) {
|
|
||||||
longPressTriggeredRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (clickTimerRef.current) {
|
|
||||||
clearTimeout(clickTimerRef.current);
|
|
||||||
clickTimerRef.current = undefined;
|
|
||||||
startEditing();
|
|
||||||
} else {
|
|
||||||
clickTimerRef.current = setTimeout(() => {
|
|
||||||
clickTimerRef.current = undefined;
|
|
||||||
onShowStatBlock?.();
|
|
||||||
}, 250);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[startEditing, onShowStatBlock],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTouchStart = useCallback(() => {
|
|
||||||
longPressTriggeredRef.current = false;
|
|
||||||
longPressTimerRef.current = setTimeout(() => {
|
|
||||||
longPressTriggeredRef.current = true;
|
|
||||||
startEditing();
|
|
||||||
}, 500);
|
|
||||||
}, [startEditing]);
|
|
||||||
|
|
||||||
const cancelLongPress = useCallback(() => {
|
|
||||||
clearTimeout(longPressTimerRef.current);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (editing) {
|
if (editing) {
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
@@ -136,30 +91,24 @@ function EditableName({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClick}
|
onClick={startEditing}
|
||||||
onTouchStart={handleTouchStart}
|
className="cursor-text truncate text-left text-foreground text-sm transition-colors hover:text-hover-neutral"
|
||||||
onTouchEnd={cancelLongPress}
|
|
||||||
onTouchCancel={cancelLongPress}
|
|
||||||
onTouchMove={cancelLongPress}
|
|
||||||
className="truncate text-left text-sm text-foreground cursor-text hover:text-hover-neutral transition-colors"
|
|
||||||
style={color ? { color } : undefined}
|
style={color ? { color } : undefined}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MaxHpDisplay({
|
function MaxHpDisplay({
|
||||||
maxHp,
|
maxHp,
|
||||||
onCommit,
|
onCommit,
|
||||||
}: {
|
}: Readonly<{
|
||||||
maxHp: number | undefined;
|
maxHp: number | undefined;
|
||||||
onCommit: (value: number | undefined) => void;
|
onCommit: (value: number | undefined) => void;
|
||||||
}) {
|
}>) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(maxHp?.toString() ?? "");
|
const [draft, setDraft] = useState(maxHp?.toString() ?? "");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -205,7 +154,7 @@ function MaxHpDisplay({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={startEditing}
|
onClick={startEditing}
|
||||||
className="inline-block h-7 min-w-[3ch] text-center text-sm leading-7 tabular-nums text-muted-foreground transition-colors hover:text-hover-neutral"
|
className="inline-block h-7 min-w-[3ch] text-center text-muted-foreground text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral"
|
||||||
>
|
>
|
||||||
{maxHp ?? "Max"}
|
{maxHp ?? "Max"}
|
||||||
</button>
|
</button>
|
||||||
@@ -217,12 +166,12 @@ function ClickableHp({
|
|||||||
maxHp,
|
maxHp,
|
||||||
onAdjust,
|
onAdjust,
|
||||||
dimmed,
|
dimmed,
|
||||||
}: {
|
}: Readonly<{
|
||||||
currentHp: number | undefined;
|
currentHp: number | undefined;
|
||||||
maxHp: number | undefined;
|
maxHp: number | undefined;
|
||||||
onAdjust: (delta: number) => void;
|
onAdjust: (delta: number) => void;
|
||||||
dimmed?: boolean;
|
dimmed?: boolean;
|
||||||
}) {
|
}>) {
|
||||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||||
const status = deriveHpStatus(currentHp, maxHp);
|
const status = deriveHpStatus(currentHp, maxHp);
|
||||||
|
|
||||||
@@ -230,9 +179,11 @@ function ClickableHp({
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-block h-7 w-[4ch] text-center text-sm leading-7 tabular-nums text-muted-foreground",
|
"inline-block h-7 w-[4ch] text-center text-muted-foreground text-sm tabular-nums leading-7",
|
||||||
dimmed && "opacity-50",
|
dimmed && "opacity-50",
|
||||||
)}
|
)}
|
||||||
|
role="status"
|
||||||
|
aria-label="No HP set"
|
||||||
>
|
>
|
||||||
--
|
--
|
||||||
</span>
|
</span>
|
||||||
@@ -244,8 +195,9 @@ function ClickableHp({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPopoverOpen(true)}
|
onClick={() => setPopoverOpen(true)}
|
||||||
|
aria-label={`Current HP: ${currentHp} (${status})`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-block h-7 min-w-[3ch] text-center text-sm font-medium leading-7 tabular-nums transition-colors hover:text-hover-neutral",
|
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral",
|
||||||
status === "bloodied" && "text-amber-400",
|
status === "bloodied" && "text-amber-400",
|
||||||
status === "unconscious" && "text-red-400",
|
status === "unconscious" && "text-red-400",
|
||||||
status === "healthy" && "text-foreground",
|
status === "healthy" && "text-foreground",
|
||||||
@@ -254,7 +206,7 @@ function ClickableHp({
|
|||||||
>
|
>
|
||||||
{currentHp}
|
{currentHp}
|
||||||
</button>
|
</button>
|
||||||
{popoverOpen && (
|
{!!popoverOpen && (
|
||||||
<HpAdjustPopover
|
<HpAdjustPopover
|
||||||
onAdjust={onAdjust}
|
onAdjust={onAdjust}
|
||||||
onClose={() => setPopoverOpen(false)}
|
onClose={() => setPopoverOpen(false)}
|
||||||
@@ -267,10 +219,10 @@ function ClickableHp({
|
|||||||
function AcDisplay({
|
function AcDisplay({
|
||||||
ac,
|
ac,
|
||||||
onCommit,
|
onCommit,
|
||||||
}: {
|
}: Readonly<{
|
||||||
ac: number | undefined;
|
ac: number | undefined;
|
||||||
onCommit: (value: number | undefined) => void;
|
onCommit: (value: number | undefined) => void;
|
||||||
}) {
|
}>) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(ac?.toString() ?? "");
|
const [draft, setDraft] = useState(ac?.toString() ?? "");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -321,13 +273,13 @@ function InitiativeDisplay({
|
|||||||
dimmed,
|
dimmed,
|
||||||
onSetInitiative,
|
onSetInitiative,
|
||||||
onRollInitiative,
|
onRollInitiative,
|
||||||
}: {
|
}: Readonly<{
|
||||||
initiative: number | undefined;
|
initiative: number | undefined;
|
||||||
combatantId: CombatantId;
|
combatantId: CombatantId;
|
||||||
dimmed: boolean;
|
dimmed: boolean;
|
||||||
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
|
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
|
||||||
onRollInitiative?: (id: CombatantId) => void;
|
onRollInitiative?: (id: CombatantId) => void;
|
||||||
}) {
|
}>) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(initiative?.toString() ?? "");
|
const [draft, setDraft] = useState(initiative?.toString() ?? "");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -397,10 +349,10 @@ function InitiativeDisplay({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={startEditing}
|
onClick={startEditing}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-7 w-full text-center text-sm leading-7 tabular-nums transition-colors",
|
"h-7 w-full text-center text-sm tabular-nums leading-7 transition-colors",
|
||||||
initiative !== undefined
|
initiative === undefined
|
||||||
? "font-medium text-foreground hover:text-hover-neutral"
|
? "text-muted-foreground hover:text-hover-neutral"
|
||||||
: "text-muted-foreground hover:text-hover-neutral",
|
: "font-medium text-foreground hover:text-hover-neutral",
|
||||||
dimmed && "opacity-50",
|
dimmed && "opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -427,17 +379,6 @@ function concentrationIconClass(
|
|||||||
return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400";
|
return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400";
|
||||||
}
|
}
|
||||||
|
|
||||||
function activateOnKeyDown(
|
|
||||||
handler: () => void,
|
|
||||||
): (e: { key: string; preventDefault: () => void }) => void {
|
|
||||||
return (e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
handler();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CombatantRow({
|
export function CombatantRow({
|
||||||
ref,
|
ref,
|
||||||
combatant,
|
combatant,
|
||||||
@@ -490,34 +431,23 @@ export function CombatantRow({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
/* biome-ignore lint/a11y/noStaticElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
|
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
role={onShowStatBlock ? "button" : undefined}
|
|
||||||
tabIndex={onShowStatBlock ? 0 : undefined}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"group rounded-md pr-3 transition-colors",
|
"group rounded-md pr-3 transition-colors",
|
||||||
rowBorderClass(isActive, combatant.isConcentrating),
|
rowBorderClass(isActive, combatant.isConcentrating),
|
||||||
isPulsing && "animate-concentration-pulse",
|
isPulsing && "animate-concentration-pulse",
|
||||||
onShowStatBlock && "cursor-pointer",
|
|
||||||
)}
|
)}
|
||||||
onClick={onShowStatBlock}
|
|
||||||
onKeyDown={
|
|
||||||
onShowStatBlock ? activateOnKeyDown(onShowStatBlock) : undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-[2rem_3rem_1fr_auto_auto_2rem] items-center gap-3 py-2">
|
<div className="grid grid-cols-[2rem_3rem_1fr_auto_auto_2rem] items-center gap-3 py-2">
|
||||||
{/* Concentration */}
|
{/* Concentration */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={() => onToggleConcentration(id)}
|
||||||
e.stopPropagation();
|
|
||||||
onToggleConcentration(id);
|
|
||||||
}}
|
|
||||||
title="Concentrating"
|
title="Concentrating"
|
||||||
aria-label="Toggle concentration"
|
aria-label="Toggle concentration"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center justify-center self-stretch -my-2 -ml-[2px] pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
|
"-my-2 -ml-[2px] flex w-full items-center justify-center self-stretch pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
|
||||||
concentrationIconClass(combatant.isConcentrating, dimmed),
|
concentrationIconClass(combatant.isConcentrating, dimmed),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -525,11 +455,6 @@ export function CombatantRow({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Initiative */}
|
{/* Initiative */}
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
|
||||||
<div
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<InitiativeDisplay
|
<InitiativeDisplay
|
||||||
initiative={initiative}
|
initiative={initiative}
|
||||||
combatantId={id}
|
combatantId={id}
|
||||||
@@ -537,27 +462,37 @@ export function CombatantRow({
|
|||||||
onSetInitiative={onSetInitiative}
|
onSetInitiative={onSetInitiative}
|
||||||
onRollInitiative={onRollInitiative}
|
onRollInitiative={onRollInitiative}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Name + Conditions */}
|
{/* Name + Conditions */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex flex-wrap items-center gap-1 min-w-0",
|
"relative flex min-w-0 flex-wrap items-center gap-1",
|
||||||
dimmed && "opacity-50",
|
dimmed && "opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{combatant.icon &&
|
{!!onShowStatBlock && (
|
||||||
combatant.color &&
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onShowStatBlock}
|
||||||
|
title="View stat block"
|
||||||
|
aria-label="View stat block"
|
||||||
|
className="shrink-0 text-muted-foreground transition-colors hover:text-hover-neutral"
|
||||||
|
>
|
||||||
|
<BookOpen size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!!combatant.icon &&
|
||||||
|
!!combatant.color &&
|
||||||
(() => {
|
(() => {
|
||||||
const PcIcon = PLAYER_ICON_MAP[combatant.icon as PlayerIcon];
|
const PcIcon = PLAYER_ICON_MAP[combatant.icon as PlayerIcon];
|
||||||
const pcColor =
|
const iconColor =
|
||||||
PLAYER_COLOR_HEX[
|
PLAYER_COLOR_HEX[
|
||||||
combatant.color as keyof typeof PLAYER_COLOR_HEX
|
combatant.color as keyof typeof PLAYER_COLOR_HEX
|
||||||
];
|
];
|
||||||
return PcIcon ? (
|
return PcIcon ? (
|
||||||
<PcIcon
|
<PcIcon
|
||||||
size={14}
|
size={14}
|
||||||
style={{ color: pcColor }}
|
style={{ color: iconColor }}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
@@ -566,7 +501,6 @@ export function CombatantRow({
|
|||||||
name={name}
|
name={name}
|
||||||
combatantId={id}
|
combatantId={id}
|
||||||
onRename={onRename}
|
onRename={onRename}
|
||||||
onShowStatBlock={onShowStatBlock}
|
|
||||||
color={pcColor}
|
color={pcColor}
|
||||||
/>
|
/>
|
||||||
<ConditionTags
|
<ConditionTags
|
||||||
@@ -574,7 +508,7 @@ export function CombatantRow({
|
|||||||
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
|
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
|
||||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||||
/>
|
/>
|
||||||
{pickerOpen && (
|
{!!pickerOpen && (
|
||||||
<ConditionPicker
|
<ConditionPicker
|
||||||
activeConditions={combatant.conditions}
|
activeConditions={combatant.conditions}
|
||||||
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
|
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
|
||||||
@@ -584,22 +518,12 @@ export function CombatantRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AC */}
|
{/* AC */}
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
<div className={cn(dimmed && "opacity-50")}>
|
||||||
<div
|
|
||||||
className={cn(dimmed && "opacity-50")}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
|
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* HP */}
|
{/* HP */}
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
<div className="flex items-center gap-1">
|
||||||
<div
|
|
||||||
className="flex items-center gap-1"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<ClickableHp
|
<ClickableHp
|
||||||
currentHp={currentHp}
|
currentHp={currentHp}
|
||||||
maxHp={maxHp}
|
maxHp={maxHp}
|
||||||
@@ -609,7 +533,7 @@ export function CombatantRow({
|
|||||||
{maxHp !== undefined && (
|
{maxHp !== undefined && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm tabular-nums text-muted-foreground",
|
"text-muted-foreground text-sm tabular-nums",
|
||||||
dimmed && "opacity-50",
|
dimmed && "opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -626,7 +550,7 @@ export function CombatantRow({
|
|||||||
icon={<X size={16} />}
|
icon={<X size={16} />}
|
||||||
label="Remove combatant"
|
label="Remove combatant"
|
||||||
onConfirm={() => onRemove(id)}
|
onConfirm={() => onRemove(id)}
|
||||||
className="h-7 w-7 text-muted-foreground opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus:opacity-100 focus:pointer-events-auto pointer-coarse:opacity-100 pointer-coarse:pointer-events-auto transition-opacity"
|
className="pointer-events-none pointer-coarse:pointer-events-auto h-7 w-7 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-opacity focus:pointer-events-auto focus:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function ConditionPicker({
|
|||||||
activeConditions,
|
activeConditions,
|
||||||
onToggle,
|
onToggle,
|
||||||
onClose,
|
onClose,
|
||||||
}: ConditionPickerProps) {
|
}: Readonly<ConditionPickerProps>) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const [flipped, setFlipped] = useState(false);
|
const [flipped, setFlipped] = useState(false);
|
||||||
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
|
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function ConditionTags({
|
|||||||
conditions,
|
conditions,
|
||||||
onRemove,
|
onRemove,
|
||||||
onOpenPicker,
|
onOpenPicker,
|
||||||
}: ConditionTagsProps) {
|
}: Readonly<ConditionTagsProps>) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-0.5">
|
<div className="flex flex-wrap items-center gap-0.5">
|
||||||
{conditions?.map((condId) => {
|
{conditions?.map((condId) => {
|
||||||
@@ -75,7 +75,7 @@ export function ConditionTags({
|
|||||||
type="button"
|
type="button"
|
||||||
title={def.label}
|
title={def.label}
|
||||||
aria-label={`Remove ${def.label}`}
|
aria-label={`Remove ${def.label}`}
|
||||||
className={`inline-flex items-center rounded p-0.5 hover:bg-hover-neutral-bg transition-colors ${colorClass}`}
|
className={`inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg ${colorClass}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRemove(condId);
|
onRemove(condId);
|
||||||
@@ -89,7 +89,7 @@ export function ConditionTags({
|
|||||||
type="button"
|
type="button"
|
||||||
title="Add condition"
|
title="Add condition"
|
||||||
aria-label="Add condition"
|
aria-label="Add condition"
|
||||||
className="inline-flex items-center rounded p-0.5 text-muted-foreground hover:text-hover-neutral hover:bg-hover-neutral-bg transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 pointer-coarse:opacity-100 transition-opacity"
|
className="inline-flex items-center rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-colors transition-opacity hover:bg-hover-neutral-bg hover:text-hover-neutral focus:opacity-100 group-hover:opacity-100"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onOpenPicker();
|
onOpenPicker();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { PlayerCharacter } from "@initiative/domain";
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { type FormEvent, useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ColorPalette } from "./color-palette";
|
import { ColorPalette } from "./color-palette";
|
||||||
import { IconGrid } from "./icon-grid";
|
import { IconGrid } from "./icon-grid";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
@@ -13,8 +13,8 @@ interface CreatePlayerModalProps {
|
|||||||
name: string,
|
name: string,
|
||||||
ac: number,
|
ac: number,
|
||||||
maxHp: number,
|
maxHp: number,
|
||||||
color: string,
|
color: string | undefined,
|
||||||
icon: string,
|
icon: string | undefined,
|
||||||
) => void;
|
) => void;
|
||||||
playerCharacter?: PlayerCharacter;
|
playerCharacter?: PlayerCharacter;
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,8 @@ export function CreatePlayerModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
playerCharacter,
|
playerCharacter,
|
||||||
}: CreatePlayerModalProps) {
|
}: Readonly<CreatePlayerModalProps>) {
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [ac, setAc] = useState("10");
|
const [ac, setAc] = useState("10");
|
||||||
const [maxHp, setMaxHp] = useState("10");
|
const [maxHp, setMaxHp] = useState("10");
|
||||||
@@ -40,31 +41,48 @@ export function CreatePlayerModal({
|
|||||||
setName(playerCharacter.name);
|
setName(playerCharacter.name);
|
||||||
setAc(String(playerCharacter.ac));
|
setAc(String(playerCharacter.ac));
|
||||||
setMaxHp(String(playerCharacter.maxHp));
|
setMaxHp(String(playerCharacter.maxHp));
|
||||||
setColor(playerCharacter.color);
|
setColor(playerCharacter.color ?? "");
|
||||||
setIcon(playerCharacter.icon);
|
setIcon(playerCharacter.icon ?? "");
|
||||||
} else {
|
} else {
|
||||||
setName("");
|
setName("");
|
||||||
setAc("10");
|
setAc("10");
|
||||||
setMaxHp("10");
|
setMaxHp("10");
|
||||||
setColor("blue");
|
setColor("");
|
||||||
setIcon("sword");
|
setIcon("");
|
||||||
}
|
}
|
||||||
setError("");
|
setError("");
|
||||||
}
|
}
|
||||||
}, [open, playerCharacter]);
|
}, [open, playerCharacter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
const dialog = dialogRef.current;
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
if (!dialog) return;
|
||||||
if (e.key === "Escape") onClose();
|
if (open && !dialog.open) {
|
||||||
|
dialog.showModal();
|
||||||
|
} else if (!open && dialog.open) {
|
||||||
|
dialog.close();
|
||||||
}
|
}
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
}, [open]);
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [open, onClose]);
|
|
||||||
|
|
||||||
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();
|
e.preventDefault();
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
if (trimmed === "") {
|
if (trimmed === "") {
|
||||||
@@ -81,23 +99,17 @@ export function CreatePlayerModal({
|
|||||||
setError("Max HP must be at least 1");
|
setError("Max HP must be at least 1");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSave(trimmed, acNum, hpNum, color, icon);
|
onSave(trimmed, acNum, hpNum, color || undefined, icon || undefined);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
|
<dialog
|
||||||
<div
|
ref={dialogRef}
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl backdrop:bg-black/50"
|
||||||
onMouseDown={onClose}
|
|
||||||
>
|
|
||||||
{/* 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">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
<h2 className="font-semibold text-foreground text-lg">
|
||||||
{isEdit ? "Edit Player" : "Create Player"}
|
{isEdit ? "Edit Player" : "Create Player"}
|
||||||
</h2>
|
</h2>
|
||||||
<Button
|
<Button
|
||||||
@@ -112,9 +124,7 @@ export function CreatePlayerModal({
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="mb-1 block text-sm text-muted-foreground">
|
<span className="mb-1 block text-muted-foreground text-sm">Name</span>
|
||||||
Name
|
|
||||||
</span>
|
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
@@ -126,14 +136,12 @@ export function CreatePlayerModal({
|
|||||||
aria-label="Name"
|
aria-label="Name"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{error && <p className="mt-1 text-sm text-destructive">{error}</p>}
|
{!!error && <p className="mt-1 text-destructive text-sm">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span className="mb-1 block text-sm text-muted-foreground">
|
<span className="mb-1 block text-muted-foreground text-sm">AC</span>
|
||||||
AC
|
|
||||||
</span>
|
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
@@ -145,7 +153,7 @@ export function CreatePlayerModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span className="mb-1 block text-sm text-muted-foreground">
|
<span className="mb-1 block text-muted-foreground text-sm">
|
||||||
Max HP
|
Max HP
|
||||||
</span>
|
</span>
|
||||||
<Input
|
<Input
|
||||||
@@ -161,16 +169,14 @@ export function CreatePlayerModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className="mb-2 block text-sm text-muted-foreground">
|
<span className="mb-2 block text-muted-foreground text-sm">
|
||||||
Color
|
Color
|
||||||
</span>
|
</span>
|
||||||
<ColorPalette value={color} onChange={setColor} />
|
<ColorPalette value={color} onChange={setColor} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className="mb-2 block text-sm text-muted-foreground">
|
<span className="mb-2 block text-muted-foreground text-sm">Icon</span>
|
||||||
Icon
|
|
||||||
</span>
|
|
||||||
<IconGrid value={icon} onChange={setIcon} />
|
<IconGrid value={icon} onChange={setIcon} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -181,7 +187,6 @@ export function CreatePlayerModal({
|
|||||||
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
|
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</dialog>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
|
|
||||||
|
const DIGITS_ONLY_REGEX = /^\d+$/;
|
||||||
|
|
||||||
interface HpAdjustPopoverProps {
|
interface HpAdjustPopoverProps {
|
||||||
readonly onAdjust: (delta: number) => void;
|
readonly onAdjust: (delta: number) => void;
|
||||||
readonly onClose: () => void;
|
readonly onClose: () => void;
|
||||||
@@ -102,7 +104,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
|||||||
className="h-7 w-[7ch] text-center text-sm tabular-nums"
|
className="h-7 w-[7ch] text-center text-sm tabular-nums"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = e.target.value;
|
const v = e.target.value;
|
||||||
if (v === "" || /^\d+$/.test(v)) {
|
if (v === "" || DIGITS_ONLY_REGEX.test(v)) {
|
||||||
setInputValue(v);
|
setInputValue(v);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface IconGridProps {
|
|||||||
|
|
||||||
const ICONS = [...VALID_PLAYER_ICONS] as PlayerIcon[];
|
const ICONS = [...VALID_PLAYER_ICONS] as PlayerIcon[];
|
||||||
|
|
||||||
export function IconGrid({ value, onChange }: IconGridProps) {
|
export function IconGrid({ value, onChange }: Readonly<IconGridProps>) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{ICONS.map((iconId) => {
|
{ICONS.map((iconId) => {
|
||||||
@@ -19,11 +19,11 @@ export function IconGrid({ value, onChange }: IconGridProps) {
|
|||||||
<button
|
<button
|
||||||
key={iconId}
|
key={iconId}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(iconId)}
|
onClick={() => onChange(value === iconId ? "" : iconId)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-9 items-center justify-center rounded-md transition-all",
|
"flex h-9 w-9 items-center justify-center rounded-md transition-all",
|
||||||
value === iconId
|
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",
|
: "text-muted-foreground hover:bg-card hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
aria-label={iconId}
|
aria-label={iconId}
|
||||||
|
|||||||
93
apps/web/src/components/player-character-section.tsx
Normal file
93
apps/web/src/components/player-character-section.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||||
|
import { type RefObject, useImperativeHandle, useState } from "react";
|
||||||
|
import { CreatePlayerModal } from "./create-player-modal.js";
|
||||||
|
import { PlayerManagement } from "./player-management.js";
|
||||||
|
|
||||||
|
export interface PlayerCharacterSectionHandle {
|
||||||
|
openManagement: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlayerCharacterSectionProps {
|
||||||
|
characters: readonly PlayerCharacter[];
|
||||||
|
onCreateCharacter: (
|
||||||
|
name: string,
|
||||||
|
ac: number,
|
||||||
|
maxHp: number,
|
||||||
|
color: string | undefined,
|
||||||
|
icon: string | undefined,
|
||||||
|
) => void;
|
||||||
|
onEditCharacter: (
|
||||||
|
id: PlayerCharacterId,
|
||||||
|
fields: {
|
||||||
|
name?: string;
|
||||||
|
ac?: number;
|
||||||
|
maxHp?: number;
|
||||||
|
color?: string | null;
|
||||||
|
icon?: string | null;
|
||||||
|
},
|
||||||
|
) => void;
|
||||||
|
onDeleteCharacter: (id: PlayerCharacterId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||||
|
characters,
|
||||||
|
onCreateCharacter,
|
||||||
|
onEditCharacter,
|
||||||
|
onDeleteCharacter,
|
||||||
|
ref,
|
||||||
|
}: PlayerCharacterSectionProps & {
|
||||||
|
ref?: RefObject<PlayerCharacterSectionHandle | null>;
|
||||||
|
}) {
|
||||||
|
const [managementOpen, setManagementOpen] = useState(false);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [editingPlayer, setEditingPlayer] = useState<
|
||||||
|
PlayerCharacter | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
openManagement: () => setManagementOpen(true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CreatePlayerModal
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setCreateOpen(false);
|
||||||
|
setEditingPlayer(undefined);
|
||||||
|
setManagementOpen(true);
|
||||||
|
}}
|
||||||
|
onSave={(name, ac, maxHp, color, icon) => {
|
||||||
|
if (editingPlayer) {
|
||||||
|
onEditCharacter(editingPlayer.id, {
|
||||||
|
name,
|
||||||
|
ac,
|
||||||
|
maxHp,
|
||||||
|
color: color ?? null,
|
||||||
|
icon: icon ?? null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onCreateCharacter(name, ac, maxHp, color, icon);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
playerCharacter={editingPlayer}
|
||||||
|
/>
|
||||||
|
<PlayerManagement
|
||||||
|
open={managementOpen}
|
||||||
|
onClose={() => setManagementOpen(false)}
|
||||||
|
characters={characters}
|
||||||
|
onEdit={(pc) => {
|
||||||
|
setEditingPlayer(pc);
|
||||||
|
setCreateOpen(true);
|
||||||
|
setManagementOpen(false);
|
||||||
|
}}
|
||||||
|
onDelete={(id) => onDeleteCharacter(id)}
|
||||||
|
onCreate={() => {
|
||||||
|
setEditingPlayer(undefined);
|
||||||
|
setCreateOpen(true);
|
||||||
|
setManagementOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
import type {
|
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||||
PlayerCharacter,
|
|
||||||
PlayerCharacterId,
|
|
||||||
PlayerIcon,
|
|
||||||
} from "@initiative/domain";
|
|
||||||
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
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 { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { ConfirmButton } from "./ui/confirm-button";
|
import { ConfirmButton } from "./ui/confirm-button";
|
||||||
@@ -25,31 +21,44 @@ export function PlayerManagement({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onCreate,
|
onCreate,
|
||||||
}: PlayerManagementProps) {
|
}: Readonly<PlayerManagementProps>) {
|
||||||
useEffect(() => {
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
if (!open) return;
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
}
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [open, onClose]);
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
|
<dialog
|
||||||
<div
|
ref={dialogRef}
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl backdrop:bg-black/50"
|
||||||
onMouseDown={onClose}
|
|
||||||
>
|
|
||||||
{/* 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">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
<h2 className="font-semibold text-foreground text-lg">
|
||||||
Player Characters
|
Player Characters
|
||||||
</h2>
|
</h2>
|
||||||
<Button
|
<Button
|
||||||
@@ -73,24 +82,23 @@ export function PlayerManagement({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{characters.map((pc) => {
|
{characters.map((pc) => {
|
||||||
const Icon = PLAYER_ICON_MAP[pc.icon as PlayerIcon];
|
const Icon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
|
||||||
const color =
|
const color = pc.color ? PLAYER_COLOR_HEX[pc.color] : undefined;
|
||||||
PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX];
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={pc.id}
|
key={pc.id}
|
||||||
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-hover-neutral-bg"
|
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-hover-neutral-bg"
|
||||||
>
|
>
|
||||||
{Icon && (
|
{!!Icon && (
|
||||||
<Icon size={18} style={{ color }} className="shrink-0" />
|
<Icon size={18} style={{ color }} className="shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="flex-1 truncate text-sm text-foreground">
|
<span className="flex-1 truncate text-foreground text-sm">
|
||||||
{pc.name}
|
{pc.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs tabular-nums text-muted-foreground">
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
AC {pc.ac}
|
AC {pc.ac}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs tabular-nums text-muted-foreground">
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
HP {pc.maxHp}
|
HP {pc.maxHp}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
@@ -120,7 +128,6 @@ export function PlayerManagement({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</dialog>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Download, Loader2, Upload } from "lucide-react";
|
import { Download, Loader2, Upload } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useId, useRef, useState } from "react";
|
||||||
import { getDefaultFetchUrl } from "../adapters/bestiary-index-adapter.js";
|
import { getDefaultFetchUrl } from "../adapters/bestiary-index-adapter.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
@@ -18,11 +18,12 @@ export function SourceFetchPrompt({
|
|||||||
fetchAndCacheSource,
|
fetchAndCacheSource,
|
||||||
onSourceLoaded,
|
onSourceLoaded,
|
||||||
onUploadSource,
|
onUploadSource,
|
||||||
}: SourceFetchPromptProps) {
|
}: Readonly<SourceFetchPromptProps>) {
|
||||||
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
|
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
|
||||||
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
|
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
|
||||||
const [error, setError] = useState<string>("");
|
const [error, setError] = useState<string>("");
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const sourceUrlId = useId();
|
||||||
|
|
||||||
const handleFetch = async () => {
|
const handleFetch = async () => {
|
||||||
setStatus("fetching");
|
setStatus("fetching");
|
||||||
@@ -64,21 +65,21 @@ export function SourceFetchPrompt({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-foreground">
|
<h3 className="font-semibold text-foreground text-sm">
|
||||||
Load {sourceDisplayName}
|
Load {sourceDisplayName}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-muted-foreground text-xs">
|
||||||
Stat block data for this source needs to be loaded. Enter a URL or
|
Stat block data for this source needs to be loaded. Enter a URL or
|
||||||
upload a JSON file.
|
upload a JSON file.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="source-url" className="text-xs text-muted-foreground">
|
<label htmlFor={sourceUrlId} className="text-muted-foreground text-xs">
|
||||||
Source URL
|
Source URL
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="source-url"
|
id={sourceUrlId}
|
||||||
type="url"
|
type="url"
|
||||||
value={url}
|
value={url}
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
@@ -97,7 +98,7 @@ export function SourceFetchPrompt({
|
|||||||
{status === "fetching" ? "Loading..." : "Load"}
|
{status === "fetching" ? "Loading..." : "Load"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<span className="text-xs text-muted-foreground">or</span>
|
<span className="text-muted-foreground text-xs">or</span>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -117,7 +118,7 @@ export function SourceFetchPrompt({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status === "error" && (
|
{status === "error" && (
|
||||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-destructive text-xs">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Database, Trash2 } from "lucide-react";
|
import { Database, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useOptimistic, useState } from "react";
|
||||||
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
|
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
|
||||||
import * as bestiaryCache from "../adapters/bestiary-cache.js";
|
import * as bestiaryCache from "../adapters/bestiary-cache.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
@@ -8,8 +8,20 @@ interface SourceManagerProps {
|
|||||||
onCacheCleared: () => void;
|
onCacheCleared: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SourceManager({ onCacheCleared }: SourceManagerProps) {
|
export function SourceManager({
|
||||||
|
onCacheCleared,
|
||||||
|
}: Readonly<SourceManagerProps>) {
|
||||||
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
||||||
|
const [optimisticSources, applyOptimistic] = useOptimistic(
|
||||||
|
sources,
|
||||||
|
(
|
||||||
|
state,
|
||||||
|
action: { type: "remove"; sourceCode: string } | { type: "clear" },
|
||||||
|
) =>
|
||||||
|
action.type === "clear"
|
||||||
|
? []
|
||||||
|
: state.filter((s) => s.sourceCode !== action.sourceCode),
|
||||||
|
);
|
||||||
|
|
||||||
const loadSources = useCallback(async () => {
|
const loadSources = useCallback(async () => {
|
||||||
const cached = await bestiaryCache.getCachedSources();
|
const cached = await bestiaryCache.getCachedSources();
|
||||||
@@ -17,26 +29,28 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSources();
|
void loadSources();
|
||||||
}, [loadSources]);
|
}, [loadSources]);
|
||||||
|
|
||||||
const handleClearSource = async (sourceCode: string) => {
|
const handleClearSource = async (sourceCode: string) => {
|
||||||
|
applyOptimistic({ type: "remove", sourceCode });
|
||||||
await bestiaryCache.clearSource(sourceCode);
|
await bestiaryCache.clearSource(sourceCode);
|
||||||
await loadSources();
|
await loadSources();
|
||||||
onCacheCleared();
|
onCacheCleared();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearAll = async () => {
|
const handleClearAll = async () => {
|
||||||
|
applyOptimistic({ type: "clear" });
|
||||||
await bestiaryCache.clearAll();
|
await bestiaryCache.clearAll();
|
||||||
await loadSources();
|
await loadSources();
|
||||||
onCacheCleared();
|
onCacheCleared();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (sources.length === 0) {
|
if (optimisticSources.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||||
<Database className="h-8 w-8 text-muted-foreground" />
|
<Database className="h-8 w-8 text-muted-foreground" />
|
||||||
<p className="text-sm text-muted-foreground">No cached sources</p>
|
<p className="text-muted-foreground text-sm">No cached sources</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -44,12 +58,12 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-semibold text-foreground">
|
<span className="font-semibold text-foreground text-sm">
|
||||||
Cached Sources
|
Cached Sources
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="hover:text-hover-destructive hover:border-hover-destructive"
|
className="hover:border-hover-destructive hover:text-hover-destructive"
|
||||||
onClick={handleClearAll}
|
onClick={handleClearAll}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-1 h-3 w-3" />
|
<Trash2 className="mr-1 h-3 w-3" />
|
||||||
@@ -57,16 +71,16 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<ul className="flex flex-col gap-1">
|
<ul className="flex flex-col gap-1">
|
||||||
{sources.map((source) => (
|
{optimisticSources.map((source) => (
|
||||||
<li
|
<li
|
||||||
key={source.sourceCode}
|
key={source.sourceCode}
|
||||||
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
|
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm text-foreground">
|
<span className="text-foreground text-sm">
|
||||||
{source.displayName}
|
{source.displayName}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-2 text-xs text-muted-foreground">
|
<span className="ml-2 text-muted-foreground text-xs">
|
||||||
{source.creatureCount} creatures
|
{source.creatureCount} creatures
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,6 +88,7 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleClearSource(source.sourceCode)}
|
onClick={() => handleClearSource(source.sourceCode)}
|
||||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-hover-destructive-bg hover:text-hover-destructive"
|
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-hover-destructive-bg hover:text-hover-destructive"
|
||||||
|
aria-label={`Remove ${source.displayName}`}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
|||||||
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
||||||
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
||||||
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
||||||
|
import { SourceManager } from "./source-manager.js";
|
||||||
import { StatBlock } from "./stat-block.js";
|
import { StatBlock } from "./stat-block.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
|
|
||||||
@@ -21,8 +22,8 @@ interface StatBlockPanelProps {
|
|||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
refreshCache: () => Promise<void>;
|
refreshCache: () => Promise<void>;
|
||||||
panelRole: "browse" | "pinned";
|
panelRole: "browse" | "pinned";
|
||||||
isFolded: boolean;
|
isCollapsed: boolean;
|
||||||
onToggleFold: () => void;
|
onToggleCollapse: () => void;
|
||||||
onPin: () => void;
|
onPin: () => void;
|
||||||
onUnpin: () => void;
|
onUnpin: () => void;
|
||||||
showPinButton: boolean;
|
showPinButton: boolean;
|
||||||
@@ -32,6 +33,7 @@ interface StatBlockPanelProps {
|
|||||||
bulkImportState?: BulkImportState;
|
bulkImportState?: BulkImportState;
|
||||||
onStartBulkImport?: (baseUrl: string) => void;
|
onStartBulkImport?: (baseUrl: string) => void;
|
||||||
onBulkImportDone?: () => void;
|
onBulkImportDone?: () => void;
|
||||||
|
sourceManagerMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractSourceCode(cId: CreatureId): string {
|
function extractSourceCode(cId: CreatureId): string {
|
||||||
@@ -40,25 +42,25 @@ function extractSourceCode(cId: CreatureId): string {
|
|||||||
return cId.slice(0, colonIndex).toUpperCase();
|
return cId.slice(0, colonIndex).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function FoldedTab({
|
function CollapsedTab({
|
||||||
creatureName,
|
creatureName,
|
||||||
side,
|
side,
|
||||||
onToggleFold,
|
onToggleCollapse,
|
||||||
}: {
|
}: Readonly<{
|
||||||
creatureName: string;
|
creatureName: string;
|
||||||
side: "left" | "right";
|
side: "left" | "right";
|
||||||
onToggleFold: () => void;
|
onToggleCollapse: () => void;
|
||||||
}) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggleFold}
|
onClick={onToggleCollapse}
|
||||||
className={`flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral ${
|
className={`flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral ${
|
||||||
side === "right" ? "self-start" : "self-end"
|
side === "right" ? "self-start" : "self-end"
|
||||||
}`}
|
}`}
|
||||||
aria-label="Unfold stat block panel"
|
aria-label="Expand stat block panel"
|
||||||
>
|
>
|
||||||
<span className="writing-vertical-rl text-sm font-medium">
|
<span className="writing-vertical-rl font-medium text-sm">
|
||||||
{creatureName}
|
{creatureName}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -68,26 +70,26 @@ function FoldedTab({
|
|||||||
function PanelHeader({
|
function PanelHeader({
|
||||||
panelRole,
|
panelRole,
|
||||||
showPinButton,
|
showPinButton,
|
||||||
onToggleFold,
|
onToggleCollapse,
|
||||||
onPin,
|
onPin,
|
||||||
onUnpin,
|
onUnpin,
|
||||||
}: {
|
}: Readonly<{
|
||||||
panelRole: "browse" | "pinned";
|
panelRole: "browse" | "pinned";
|
||||||
showPinButton: boolean;
|
showPinButton: boolean;
|
||||||
onToggleFold: () => void;
|
onToggleCollapse: () => void;
|
||||||
onPin: () => void;
|
onPin: () => void;
|
||||||
onUnpin: () => void;
|
onUnpin: () => void;
|
||||||
}) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
<div className="flex items-center justify-between border-border border-b px-4 py-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{panelRole === "browse" && (
|
{panelRole === "browse" && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={onToggleFold}
|
onClick={onToggleCollapse}
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
aria-label="Fold stat block panel"
|
aria-label="Collapse stat block panel"
|
||||||
>
|
>
|
||||||
<PanelRightClose className="h-4 w-4" />
|
<PanelRightClose className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -122,48 +124,48 @@ function PanelHeader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DesktopPanel({
|
function DesktopPanel({
|
||||||
isFolded,
|
isCollapsed,
|
||||||
side,
|
side,
|
||||||
creatureName,
|
creatureName,
|
||||||
panelRole,
|
panelRole,
|
||||||
showPinButton,
|
showPinButton,
|
||||||
onToggleFold,
|
onToggleCollapse,
|
||||||
onPin,
|
onPin,
|
||||||
onUnpin,
|
onUnpin,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: Readonly<{
|
||||||
isFolded: boolean;
|
isCollapsed: boolean;
|
||||||
side: "left" | "right";
|
side: "left" | "right";
|
||||||
creatureName: string;
|
creatureName: string;
|
||||||
panelRole: "browse" | "pinned";
|
panelRole: "browse" | "pinned";
|
||||||
showPinButton: boolean;
|
showPinButton: boolean;
|
||||||
onToggleFold: () => void;
|
onToggleCollapse: () => void;
|
||||||
onPin: () => void;
|
onPin: () => void;
|
||||||
onUnpin: () => void;
|
onUnpin: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}>) {
|
||||||
const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l";
|
const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l";
|
||||||
const foldedTranslate =
|
const collapsedTranslate =
|
||||||
side === "right"
|
side === "right"
|
||||||
? "translate-x-[calc(100%-40px)]"
|
? "translate-x-[calc(100%-40px)]"
|
||||||
: "translate-x-[calc(-100%+40px)]";
|
: "translate-x-[calc(-100%+40px)]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel ${sideClasses} ${isFolded ? foldedTranslate : "translate-x-0"}`}
|
className={`fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel ${sideClasses} ${isCollapsed ? collapsedTranslate : "translate-x-0"}`}
|
||||||
>
|
>
|
||||||
{isFolded ? (
|
{isCollapsed ? (
|
||||||
<FoldedTab
|
<CollapsedTab
|
||||||
creatureName={creatureName}
|
creatureName={creatureName}
|
||||||
side={side}
|
side={side}
|
||||||
onToggleFold={onToggleFold}
|
onToggleCollapse={onToggleCollapse}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
panelRole={panelRole}
|
panelRole={panelRole}
|
||||||
showPinButton={showPinButton}
|
showPinButton={showPinButton}
|
||||||
onToggleFold={onToggleFold}
|
onToggleCollapse={onToggleCollapse}
|
||||||
onPin={onPin}
|
onPin={onPin}
|
||||||
onUnpin={onUnpin}
|
onUnpin={onUnpin}
|
||||||
/>
|
/>
|
||||||
@@ -177,34 +179,34 @@ function DesktopPanel({
|
|||||||
function MobileDrawer({
|
function MobileDrawer({
|
||||||
onDismiss,
|
onDismiss,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: Readonly<{
|
||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}>) {
|
||||||
const { offsetX, isSwiping, handlers } = useSwipeToDismiss(onDismiss);
|
const { offsetX, isSwiping, handlers } = useSwipeToDismiss(onDismiss);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50">
|
<div className="fixed inset-0 z-50">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute inset-0 bg-black/50 animate-in fade-in"
|
className="fade-in absolute inset-0 animate-in bg-black/50"
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
aria-label="Close stat block"
|
aria-label="Close stat block"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-l border-border bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`}
|
className={`absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-border border-l bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`}
|
||||||
style={
|
style={
|
||||||
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
|
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
|
||||||
}
|
}
|
||||||
{...handlers}
|
{...handlers}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
<div className="flex items-center justify-between border-border border-b px-4 py-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
aria-label="Fold stat block panel"
|
aria-label="Collapse stat block panel"
|
||||||
>
|
>
|
||||||
<PanelRightClose className="h-4 w-4" />
|
<PanelRightClose className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -225,8 +227,8 @@ export function StatBlockPanel({
|
|||||||
uploadAndCacheSource,
|
uploadAndCacheSource,
|
||||||
refreshCache,
|
refreshCache,
|
||||||
panelRole,
|
panelRole,
|
||||||
isFolded,
|
isCollapsed,
|
||||||
onToggleFold,
|
onToggleCollapse,
|
||||||
onPin,
|
onPin,
|
||||||
onUnpin,
|
onUnpin,
|
||||||
showPinButton,
|
showPinButton,
|
||||||
@@ -236,15 +238,16 @@ export function StatBlockPanel({
|
|||||||
bulkImportState,
|
bulkImportState,
|
||||||
onStartBulkImport,
|
onStartBulkImport,
|
||||||
onBulkImportDone,
|
onBulkImportDone,
|
||||||
}: StatBlockPanelProps) {
|
sourceManagerMode,
|
||||||
|
}: Readonly<StatBlockPanelProps>) {
|
||||||
const [isDesktop, setIsDesktop] = useState(
|
const [isDesktop, setIsDesktop] = useState(
|
||||||
() => window.matchMedia("(min-width: 1024px)").matches,
|
() => globalThis.matchMedia("(min-width: 1024px)").matches,
|
||||||
);
|
);
|
||||||
const [needsFetch, setNeedsFetch] = useState(false);
|
const [needsFetch, setNeedsFetch] = useState(false);
|
||||||
const [checkingCache, setCheckingCache] = useState(false);
|
const [checkingCache, setCheckingCache] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mq = window.matchMedia("(min-width: 1024px)");
|
const mq = globalThis.matchMedia("(min-width: 1024px)");
|
||||||
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
||||||
mq.addEventListener("change", handler);
|
mq.addEventListener("change", handler);
|
||||||
return () => mq.removeEventListener("change", handler);
|
return () => mq.removeEventListener("change", handler);
|
||||||
@@ -263,13 +266,13 @@ export function StatBlockPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setCheckingCache(true);
|
setCheckingCache(true);
|
||||||
isSourceCached(sourceCode).then((cached) => {
|
void isSourceCached(sourceCode).then((cached) => {
|
||||||
setNeedsFetch(!cached);
|
setNeedsFetch(!cached);
|
||||||
setCheckingCache(false);
|
setCheckingCache(false);
|
||||||
});
|
});
|
||||||
}, [creatureId, creature, isSourceCached]);
|
}, [creatureId, creature, isSourceCached]);
|
||||||
|
|
||||||
if (!creatureId && !bulkImportMode) return null;
|
if (!creatureId && !bulkImportMode && !sourceManagerMode) return null;
|
||||||
|
|
||||||
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
||||||
|
|
||||||
@@ -279,6 +282,10 @@ export function StatBlockPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
|
if (sourceManagerMode) {
|
||||||
|
return <SourceManager onCacheCleared={refreshCache} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
bulkImportMode &&
|
bulkImportMode &&
|
||||||
bulkImportState &&
|
bulkImportState &&
|
||||||
@@ -296,7 +303,7 @@ export function StatBlockPanel({
|
|||||||
|
|
||||||
if (checkingCache) {
|
if (checkingCache) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-sm text-muted-foreground">Loading...</div>
|
<div className="p-4 text-muted-foreground text-sm">Loading...</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,24 +324,26 @@ export function StatBlockPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-sm text-muted-foreground">
|
<div className="p-4 text-muted-foreground text-sm">
|
||||||
No stat block available
|
No stat block available
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const creatureName =
|
let fallbackName = "Creature";
|
||||||
creature?.name ?? (bulkImportMode ? "Bulk Import" : "Creature");
|
if (sourceManagerMode) fallbackName = "Sources";
|
||||||
|
else if (bulkImportMode) fallbackName = "Import All Sources";
|
||||||
|
const creatureName = creature?.name ?? fallbackName;
|
||||||
|
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
return (
|
return (
|
||||||
<DesktopPanel
|
<DesktopPanel
|
||||||
isFolded={isFolded}
|
isCollapsed={isCollapsed}
|
||||||
side={side}
|
side={side}
|
||||||
creatureName={creatureName}
|
creatureName={creatureName}
|
||||||
panelRole={panelRole}
|
panelRole={panelRole}
|
||||||
showPinButton={showPinButton}
|
showPinButton={showPinButton}
|
||||||
onToggleFold={onToggleFold}
|
onToggleCollapse={onToggleCollapse}
|
||||||
onPin={onPin}
|
onPin={onPin}
|
||||||
onUnpin={onUnpin}
|
onUnpin={onUnpin}
|
||||||
>
|
>
|
||||||
@@ -343,7 +352,7 @@ export function StatBlockPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (panelRole === "pinned") return null;
|
if (panelRole === "pinned" || isCollapsed) return null;
|
||||||
|
|
||||||
return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
|
return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ function abilityMod(score: number): string {
|
|||||||
function PropertyLine({
|
function PropertyLine({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
}: {
|
}: Readonly<{
|
||||||
label: string;
|
label: string;
|
||||||
value: string | undefined;
|
value: string | undefined;
|
||||||
}) {
|
}>) {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
return (
|
return (
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
@@ -34,7 +34,7 @@ function SectionDivider() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatBlock({ creature }: StatBlockProps) {
|
export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||||
const abilities = [
|
const abilities = [
|
||||||
{ label: "STR", score: creature.abilities.str },
|
{ label: "STR", score: creature.abilities.str },
|
||||||
{ label: "DEX", score: creature.abilities.dex },
|
{ label: "DEX", score: creature.abilities.dex },
|
||||||
@@ -54,11 +54,11 @@ export function StatBlock({ creature }: StatBlockProps) {
|
|||||||
<div className="space-y-1 text-foreground">
|
<div className="space-y-1 text-foreground">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-amber-400">{creature.name}</h2>
|
<h2 className="font-bold text-amber-400 text-xl">{creature.name}</h2>
|
||||||
<p className="text-sm italic text-muted-foreground">
|
<p className="text-muted-foreground text-sm italic">
|
||||||
{creature.size} {creature.type}, {creature.alignment}
|
{creature.size} {creature.type}, {creature.alignment}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-muted-foreground text-xs">
|
||||||
{creature.sourceDisplayName}
|
{creature.sourceDisplayName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,7 +69,7 @@ export function StatBlock({ creature }: StatBlockProps) {
|
|||||||
<div className="space-y-0.5 text-sm">
|
<div className="space-y-0.5 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold">Armor Class</span> {creature.ac}
|
<span className="font-semibold">Armor Class</span> {creature.ac}
|
||||||
{creature.acSource && (
|
{!!creature.acSource && (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{" "}
|
{" "}
|
||||||
({creature.acSource})
|
({creature.acSource})
|
||||||
@@ -194,7 +194,7 @@ export function StatBlock({ creature }: StatBlockProps) {
|
|||||||
{creature.actions && creature.actions.length > 0 && (
|
{creature.actions && creature.actions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<SectionDivider />
|
<SectionDivider />
|
||||||
<h3 className="text-base font-bold text-amber-400">Actions</h3>
|
<h3 className="font-bold text-amber-400 text-base">Actions</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{creature.actions.map((a) => (
|
{creature.actions.map((a) => (
|
||||||
<div key={a.name} className="text-sm">
|
<div key={a.name} className="text-sm">
|
||||||
@@ -209,7 +209,7 @@ export function StatBlock({ creature }: StatBlockProps) {
|
|||||||
{creature.bonusActions && creature.bonusActions.length > 0 && (
|
{creature.bonusActions && creature.bonusActions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<SectionDivider />
|
<SectionDivider />
|
||||||
<h3 className="text-base font-bold text-amber-400">Bonus Actions</h3>
|
<h3 className="font-bold text-amber-400 text-base">Bonus Actions</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{creature.bonusActions.map((a) => (
|
{creature.bonusActions.map((a) => (
|
||||||
<div key={a.name} className="text-sm">
|
<div key={a.name} className="text-sm">
|
||||||
@@ -224,7 +224,7 @@ export function StatBlock({ creature }: StatBlockProps) {
|
|||||||
{creature.reactions && creature.reactions.length > 0 && (
|
{creature.reactions && creature.reactions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<SectionDivider />
|
<SectionDivider />
|
||||||
<h3 className="text-base font-bold text-amber-400">Reactions</h3>
|
<h3 className="font-bold text-amber-400 text-base">Reactions</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{creature.reactions.map((a) => (
|
{creature.reactions.map((a) => (
|
||||||
<div key={a.name} className="text-sm">
|
<div key={a.name} className="text-sm">
|
||||||
@@ -236,13 +236,13 @@ export function StatBlock({ creature }: StatBlockProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Legendary Actions */}
|
{/* Legendary Actions */}
|
||||||
{creature.legendaryActions && (
|
{!!creature.legendaryActions && (
|
||||||
<>
|
<>
|
||||||
<SectionDivider />
|
<SectionDivider />
|
||||||
<h3 className="text-base font-bold text-amber-400">
|
<h3 className="font-bold text-amber-400 text-base">
|
||||||
Legendary Actions
|
Legendary Actions
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm italic text-muted-foreground">
|
<p className="text-muted-foreground text-sm italic">
|
||||||
{creature.legendaryActions.preamble}
|
{creature.legendaryActions.preamble}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ export function Toast({
|
|||||||
}, [autoDismissMs, onDismiss]);
|
}, [autoDismissMs, onDismiss]);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2">
|
<div className="fixed bottom-4 left-4 z-50">
|
||||||
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg">
|
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg">
|
||||||
<span className="text-sm text-foreground">{message}</span>
|
<span className="text-foreground text-sm">{message}</span>
|
||||||
{progress !== undefined && (
|
{progress !== undefined && (
|
||||||
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
|
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function TurnNavigation({
|
|||||||
onAdvanceTurn,
|
onAdvanceTurn,
|
||||||
onRetreatTurn,
|
onRetreatTurn,
|
||||||
onClearEncounter,
|
onClearEncounter,
|
||||||
}: TurnNavigationProps) {
|
}: Readonly<TurnNavigationProps>) {
|
||||||
const hasCombatants = encounter.combatants.length > 0;
|
const hasCombatants = encounter.combatants.length > 0;
|
||||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||||
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||||
@@ -33,8 +33,8 @@ export function TurnNavigation({
|
|||||||
<StepBack className="h-5 w-5" />
|
<StepBack className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1 flex items-center justify-center gap-2 text-sm">
|
<div className="flex min-w-0 flex-1 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">
|
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
|
||||||
R{encounter.roundNumber}
|
R{encounter.roundNumber}
|
||||||
</span>
|
</span>
|
||||||
{activeCombatant ? (
|
{activeCombatant ? (
|
||||||
|
|||||||
@@ -55,17 +55,17 @@ export function ConfirmButton({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleEscapeKey(e: KeyboardEvent) {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
revert();
|
revert();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("mousedown", handleMouseDown);
|
document.addEventListener("mousedown", handleMouseDown);
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleEscapeKey);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousedown", handleMouseDown);
|
document.removeEventListener("mousedown", handleMouseDown);
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
document.removeEventListener("keydown", handleEscapeKey);
|
||||||
};
|
};
|
||||||
}, [isConfirming, revert]);
|
}, [isConfirming, revert]);
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ export function ConfirmButton({
|
|||||||
className={cn(
|
className={cn(
|
||||||
className,
|
className,
|
||||||
isConfirming
|
isConfirming
|
||||||
? "bg-destructive text-primary-foreground rounded-md animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground"
|
? "animate-confirm-pulse rounded-md bg-destructive text-primary-foreground hover:bg-destructive hover:text-primary-foreground"
|
||||||
: "hover:text-hover-destructive",
|
: "hover:text-hover-destructive",
|
||||||
)}
|
)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
@@ -110,7 +110,8 @@ export function ConfirmButton({
|
|||||||
aria-label={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
|
aria-label={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
|
||||||
title={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
|
title={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
|
||||||
>
|
>
|
||||||
{isConfirming ? <Check size={16} /> : icon}
|
{isConfirming ? <Check size={16} /> : null}
|
||||||
|
{!isConfirming && icon}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { forwardRef, type InputHTMLAttributes } from "react";
|
import type { InputHTMLAttributes, RefObject } from "react";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
type InputProps = InputHTMLAttributes<HTMLInputElement>;
|
type InputProps = InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
|
||||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
export const Input = ({
|
||||||
({ className, ...props }, ref) => {
|
className,
|
||||||
|
ref,
|
||||||
|
...props
|
||||||
|
}: InputProps & { ref?: RefObject<HTMLInputElement | null> }) => {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-foreground text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
};
|
||||||
);
|
|
||||||
|
|||||||
@@ -48,13 +48,13 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
|
|||||||
>
|
>
|
||||||
<EllipsisVertical className="h-5 w-5" />
|
<EllipsisVertical className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
{open && (
|
{!!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">
|
<div className="absolute right-0 bottom-full z-50 mb-1 min-w-48 rounded-md border border-border bg-card py-1 shadow-lg">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.label}
|
key={item.label}
|
||||||
type="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 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}
|
disabled={item.disabled}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
item.onClick();
|
item.onClick();
|
||||||
|
|||||||
217
apps/web/src/hooks/__tests__/use-encounter.test.ts
Normal file
217
apps/web/src/hooks/__tests__/use-encounter.test.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import type { BestiaryIndexEntry, PlayerCharacter } from "@initiative/domain";
|
||||||
|
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useEncounter } from "../use-encounter.js";
|
||||||
|
|
||||||
|
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||||
|
loadEncounter: vi.fn().mockReturnValue(null),
|
||||||
|
saveEncounter: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { loadEncounter: mockLoad, saveEncounter: mockSave } =
|
||||||
|
await vi.importMock<typeof import("../../persistence/encounter-storage.js")>(
|
||||||
|
"../../persistence/encounter-storage.js",
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("useEncounter", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockLoad.mockReturnValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializes with empty encounter when persistence returns null", () => {
|
||||||
|
const { result } = renderHook(() => useEncounter());
|
||||||
|
|
||||||
|
expect(result.current.encounter.combatants).toEqual([]);
|
||||||
|
expect(result.current.encounter.activeIndex).toBe(0);
|
||||||
|
expect(result.current.encounter.roundNumber).toBe(1);
|
||||||
|
expect(result.current.isEmpty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializes from stored encounter", () => {
|
||||||
|
const stored = {
|
||||||
|
combatants: [{ id: combatantId("c-1"), name: "Goblin" }],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 2,
|
||||||
|
};
|
||||||
|
mockLoad.mockReturnValue(stored);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useEncounter());
|
||||||
|
|
||||||
|
expect(result.current.encounter.combatants).toHaveLength(1);
|
||||||
|
expect(result.current.encounter.roundNumber).toBe(2);
|
||||||
|
expect(result.current.isEmpty).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addCombatant adds a combatant with incremental IDs and persists", () => {
|
||||||
|
const { result } = renderHook(() => useEncounter());
|
||||||
|
|
||||||
|
act(() => result.current.addCombatant("Goblin"));
|
||||||
|
act(() => result.current.addCombatant("Orc"));
|
||||||
|
|
||||||
|
expect(result.current.encounter.combatants).toHaveLength(2);
|
||||||
|
expect(result.current.encounter.combatants[0].name).toBe("Goblin");
|
||||||
|
expect(result.current.encounter.combatants[1].name).toBe("Orc");
|
||||||
|
expect(result.current.isEmpty).toBe(false);
|
||||||
|
expect(mockSave).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removeCombatant removes a combatant and persists", () => {
|
||||||
|
const { result } = renderHook(() => useEncounter());
|
||||||
|
|
||||||
|
act(() => result.current.addCombatant("Goblin"));
|
||||||
|
const id = result.current.encounter.combatants[0].id;
|
||||||
|
|
||||||
|
act(() => result.current.removeCombatant(id));
|
||||||
|
|
||||||
|
expect(result.current.encounter.combatants).toHaveLength(0);
|
||||||
|
expect(result.current.isEmpty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advanceTurn and retreatTurn update encounter state", () => {
|
||||||
|
const { result } = renderHook(() => useEncounter());
|
||||||
|
|
||||||
|
act(() => result.current.addCombatant("Goblin"));
|
||||||
|
act(() => result.current.addCombatant("Orc"));
|
||||||
|
|
||||||
|
const initialActive = result.current.encounter.activeIndex;
|
||||||
|
|
||||||
|
act(() => result.current.advanceTurn());
|
||||||
|
expect(result.current.encounter.activeIndex).not.toBe(initialActive);
|
||||||
|
|
||||||
|
act(() => result.current.retreatTurn());
|
||||||
|
expect(result.current.encounter.activeIndex).toBe(initialActive);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearEncounter resets to empty and resets ID counter", () => {
|
||||||
|
const { result } = renderHook(() => useEncounter());
|
||||||
|
|
||||||
|
act(() => result.current.addCombatant("Goblin"));
|
||||||
|
act(() => result.current.clearEncounter());
|
||||||
|
|
||||||
|
expect(result.current.encounter.combatants).toHaveLength(0);
|
||||||
|
expect(result.current.isEmpty).toBe(true);
|
||||||
|
|
||||||
|
// After clear, IDs restart from c-1
|
||||||
|
act(() => result.current.addCombatant("Orc"));
|
||||||
|
expect(result.current.encounter.combatants[0].id).toBe("c-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addCombatant with opts applies initiative, ac, maxHp", () => {
|
||||||
|
const { result } = renderHook(() => useEncounter());
|
||||||
|
|
||||||
|
act(() =>
|
||||||
|
result.current.addCombatant("Goblin", {
|
||||||
|
initiative: 15,
|
||||||
|
ac: 13,
|
||||||
|
maxHp: 7,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const goblin = result.current.encounter.combatants[0];
|
||||||
|
expect(goblin.initiative).toBe(15);
|
||||||
|
expect(goblin.ac).toBe(13);
|
||||||
|
expect(goblin.maxHp).toBe(7);
|
||||||
|
expect(goblin.currentHp).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derived flags: hasCreatureCombatants and canRollAllInitiative", () => {
|
||||||
|
const { result } = renderHook(() => useEncounter());
|
||||||
|
|
||||||
|
// No creatures yet
|
||||||
|
expect(result.current.hasCreatureCombatants).toBe(false);
|
||||||
|
expect(result.current.canRollAllInitiative).toBe(false);
|
||||||
|
|
||||||
|
// Add from bestiary to get a creature combatant
|
||||||
|
const entry: BestiaryIndexEntry = {
|
||||||
|
name: "Goblin",
|
||||||
|
source: "MM",
|
||||||
|
ac: 15,
|
||||||
|
hp: 7,
|
||||||
|
dex: 14,
|
||||||
|
cr: "1/4",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
size: "Small",
|
||||||
|
type: "humanoid",
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => result.current.addFromBestiary(entry));
|
||||||
|
|
||||||
|
expect(result.current.hasCreatureCombatants).toBe(true);
|
||||||
|
expect(result.current.canRollAllInitiative).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addFromBestiary adds combatant with HP, AC, creatureId", () => {
|
||||||
|
const { result } = renderHook(() => useEncounter());
|
||||||
|
|
||||||
|
const entry: BestiaryIndexEntry = {
|
||||||
|
name: "Goblin",
|
||||||
|
source: "MM",
|
||||||
|
ac: 15,
|
||||||
|
hp: 7,
|
||||||
|
dex: 14,
|
||||||
|
cr: "1/4",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
size: "Small",
|
||||||
|
type: "humanoid",
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => result.current.addFromBestiary(entry));
|
||||||
|
|
||||||
|
const combatant = result.current.encounter.combatants[0];
|
||||||
|
expect(combatant.name).toBe("Goblin");
|
||||||
|
expect(combatant.maxHp).toBe(7);
|
||||||
|
expect(combatant.currentHp).toBe(7);
|
||||||
|
expect(combatant.ac).toBe(15);
|
||||||
|
expect(combatant.creatureId).toBe(creatureId("mm:goblin"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addFromBestiary auto-numbers duplicate names", () => {
|
||||||
|
const { result } = renderHook(() => useEncounter());
|
||||||
|
|
||||||
|
const entry: BestiaryIndexEntry = {
|
||||||
|
name: "Goblin",
|
||||||
|
source: "MM",
|
||||||
|
ac: 15,
|
||||||
|
hp: 7,
|
||||||
|
dex: 14,
|
||||||
|
cr: "1/4",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
size: "Small",
|
||||||
|
type: "humanoid",
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => result.current.addFromBestiary(entry));
|
||||||
|
act(() => result.current.addFromBestiary(entry));
|
||||||
|
|
||||||
|
const names = result.current.encounter.combatants.map((c) => c.name);
|
||||||
|
expect(names).toContain("Goblin 1");
|
||||||
|
expect(names).toContain("Goblin 2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addFromPlayerCharacter adds combatant with HP, AC, color, icon", () => {
|
||||||
|
const { result } = renderHook(() => useEncounter());
|
||||||
|
|
||||||
|
const pc: PlayerCharacter = {
|
||||||
|
id: playerCharacterId("pc-1"),
|
||||||
|
name: "Aria",
|
||||||
|
ac: 16,
|
||||||
|
maxHp: 30,
|
||||||
|
color: "blue",
|
||||||
|
icon: "sword",
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => result.current.addFromPlayerCharacter(pc));
|
||||||
|
|
||||||
|
const combatant = result.current.encounter.combatants[0];
|
||||||
|
expect(combatant.name).toBe("Aria");
|
||||||
|
expect(combatant.maxHp).toBe(30);
|
||||||
|
expect(combatant.currentHp).toBe(30);
|
||||||
|
expect(combatant.ac).toBe(16);
|
||||||
|
expect(combatant.color).toBe("blue");
|
||||||
|
expect(combatant.icon).toBe("sword");
|
||||||
|
expect(combatant.playerCharacterId).toBe(playerCharacterId("pc-1"));
|
||||||
|
});
|
||||||
|
});
|
||||||
100
apps/web/src/hooks/__tests__/use-player-characters.test.ts
Normal file
100
apps/web/src/hooks/__tests__/use-player-characters.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
159
apps/web/src/hooks/__tests__/use-side-panel-state.test.ts
Normal file
159
apps/web/src/hooks/__tests__/use-side-panel-state.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ import type {
|
|||||||
Creature,
|
Creature,
|
||||||
CreatureId,
|
CreatureId,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
normalizeBestiary,
|
normalizeBestiary,
|
||||||
setSourceDisplayNames,
|
setSourceDisplayNames,
|
||||||
@@ -33,8 +33,9 @@ interface BestiaryHook {
|
|||||||
|
|
||||||
export function useBestiary(): BestiaryHook {
|
export function useBestiary(): BestiaryHook {
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
const creatureMapRef = useRef<Map<CreatureId, Creature>>(new Map());
|
const [creatureMap, setCreatureMap] = useState(
|
||||||
const [, setTick] = useState(0);
|
() => new Map<CreatureId, Creature>(),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const index = loadBestiaryIndex();
|
const index = loadBestiaryIndex();
|
||||||
@@ -43,9 +44,8 @@ export function useBestiary(): BestiaryHook {
|
|||||||
setIsLoaded(true);
|
setIsLoaded(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
bestiaryCache.loadAllCachedCreatures().then((map) => {
|
void bestiaryCache.loadAllCachedCreatures().then((map) => {
|
||||||
creatureMapRef.current = map;
|
setCreatureMap(map);
|
||||||
setTick((t) => t + 1);
|
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -63,9 +63,12 @@ export function useBestiary(): BestiaryHook {
|
|||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getCreature = useCallback((id: CreatureId): Creature | undefined => {
|
const getCreature = useCallback(
|
||||||
return creatureMapRef.current.get(id);
|
(id: CreatureId): Creature | undefined => {
|
||||||
}, []);
|
return creatureMap.get(id);
|
||||||
|
},
|
||||||
|
[creatureMap],
|
||||||
|
);
|
||||||
|
|
||||||
const isSourceCachedFn = useCallback(
|
const isSourceCachedFn = useCallback(
|
||||||
(sourceCode: string): Promise<boolean> => {
|
(sourceCode: string): Promise<boolean> => {
|
||||||
@@ -86,10 +89,13 @@ export function useBestiary(): BestiaryHook {
|
|||||||
const creatures = normalizeBestiary(json);
|
const creatures = normalizeBestiary(json);
|
||||||
const displayName = getSourceDisplayName(sourceCode);
|
const displayName = getSourceDisplayName(sourceCode);
|
||||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
||||||
|
setCreatureMap((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
for (const c of creatures) {
|
for (const c of creatures) {
|
||||||
creatureMapRef.current.set(c.id, c);
|
next.set(c.id, c);
|
||||||
}
|
}
|
||||||
setTick((t) => t + 1);
|
return next;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -100,18 +106,20 @@ export function useBestiary(): BestiaryHook {
|
|||||||
const creatures = normalizeBestiary(jsonData as any);
|
const creatures = normalizeBestiary(jsonData as any);
|
||||||
const displayName = getSourceDisplayName(sourceCode);
|
const displayName = getSourceDisplayName(sourceCode);
|
||||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
||||||
|
setCreatureMap((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
for (const c of creatures) {
|
for (const c of creatures) {
|
||||||
creatureMapRef.current.set(c.id, c);
|
next.set(c.id, c);
|
||||||
}
|
}
|
||||||
setTick((t) => t + 1);
|
return next;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshCache = useCallback(async (): Promise<void> => {
|
const refreshCache = useCallback(async (): Promise<void> => {
|
||||||
const map = await bestiaryCache.loadAllCachedCreatures();
|
const map = await bestiaryCache.loadAllCachedCreatures();
|
||||||
creatureMapRef.current = map;
|
setCreatureMap(map);
|
||||||
setTick((t) => t + 1);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function useBulkImport(): BulkImportHook {
|
|||||||
countersRef.current = { completed: 0, failed: 0 };
|
countersRef.current = { completed: 0, failed: 0 };
|
||||||
setState({ status: "loading", total, completed: 0, failed: 0 });
|
setState({ status: "loading", total, completed: 0, failed: 0 });
|
||||||
|
|
||||||
(async () => {
|
void (async () => {
|
||||||
const cacheChecks = await Promise.all(
|
const cacheChecks = await Promise.all(
|
||||||
allCodes.map(async (code) => ({
|
allCodes.map(async (code) => ({
|
||||||
code,
|
code,
|
||||||
@@ -73,9 +73,15 @@ export function useBulkImport(): BulkImportHook {
|
|||||||
|
|
||||||
setState((s) => ({ ...s, completed: alreadyCached }));
|
setState((s) => ({ ...s, completed: alreadyCached }));
|
||||||
|
|
||||||
|
const batches: { code: string }[][] = [];
|
||||||
for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
|
for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
|
||||||
const batch = uncached.slice(i, i + BATCH_SIZE);
|
batches.push(uncached.slice(i, i + BATCH_SIZE));
|
||||||
await Promise.allSettled(
|
}
|
||||||
|
|
||||||
|
await batches.reduce(
|
||||||
|
(chain, batch) =>
|
||||||
|
chain.then(() =>
|
||||||
|
Promise.allSettled(
|
||||||
batch.map(async ({ code }) => {
|
batch.map(async ({ code }) => {
|
||||||
const url = getDefaultFetchUrl(code, baseUrl);
|
const url = getDefaultFetchUrl(code, baseUrl);
|
||||||
try {
|
try {
|
||||||
@@ -95,8 +101,10 @@ export function useBulkImport(): BulkImportHook {
|
|||||||
failed: countersRef.current.failed,
|
failed: countersRef.current.failed,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Promise.resolve() as Promise<unknown>,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
await refreshCache();
|
await refreshCache();
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import {
|
|||||||
saveEncounter,
|
saveEncounter,
|
||||||
} from "../persistence/encounter-storage.js";
|
} from "../persistence/encounter-storage.js";
|
||||||
|
|
||||||
|
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
|
||||||
|
|
||||||
const EMPTY_ENCOUNTER: Encounter = {
|
const EMPTY_ENCOUNTER: Encounter = {
|
||||||
combatants: [],
|
combatants: [],
|
||||||
activeIndex: 0,
|
activeIndex: 0,
|
||||||
@@ -48,7 +50,7 @@ function initializeEncounter(): Encounter {
|
|||||||
function deriveNextId(encounter: Encounter): number {
|
function deriveNextId(encounter: Encounter): number {
|
||||||
let max = 0;
|
let max = 0;
|
||||||
for (const c of encounter.combatants) {
|
for (const c of encounter.combatants) {
|
||||||
const match = /^c-(\d+)$/.exec(c.id);
|
const match = COMBATANT_ID_REGEX.exec(c.id);
|
||||||
if (match) {
|
if (match) {
|
||||||
const n = Number.parseInt(match[1], 10);
|
const n = Number.parseInt(match[1], 10);
|
||||||
if (n > max) max = n;
|
if (n > max) max = n;
|
||||||
@@ -301,8 +303,8 @@ export function useEncounter() {
|
|||||||
// Derive creatureId from source + name
|
// Derive creatureId from source + name
|
||||||
const slug = entry.name
|
const slug = entry.name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||||
.replace(/(^-|-$)/g, "");
|
.replaceAll(/(^-|-$)/g, "");
|
||||||
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
|
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
|
||||||
|
|
||||||
// Set creatureId on the combatant (use store.save to keep ref in sync for batch calls)
|
// Set creatureId on the combatant (use store.save to keep ref in sync for batch calls)
|
||||||
@@ -316,7 +318,7 @@ export function useEncounter() {
|
|||||||
|
|
||||||
setEvents((prev) => [...prev, ...addResult]);
|
setEvents((prev) => [...prev, ...addResult]);
|
||||||
},
|
},
|
||||||
[makeStore, editCombatant],
|
[makeStore],
|
||||||
);
|
);
|
||||||
|
|
||||||
const addFromPlayerCharacter = useCallback(
|
const addFromPlayerCharacter = useCallback(
|
||||||
@@ -368,12 +370,23 @@ export function useEncounter() {
|
|||||||
|
|
||||||
setEvents((prev) => [...prev, ...addResult]);
|
setEvents((prev) => [...prev, ...addResult]);
|
||||||
},
|
},
|
||||||
[makeStore, editCombatant],
|
[makeStore],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isEmpty = encounter.combatants.length === 0;
|
||||||
|
const hasCreatureCombatants = encounter.combatants.some(
|
||||||
|
(c) => c.creatureId != null,
|
||||||
|
);
|
||||||
|
const canRollAllInitiative = encounter.combatants.some(
|
||||||
|
(c) => c.creatureId != null && c.initiative == null,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
encounter,
|
encounter,
|
||||||
events,
|
events,
|
||||||
|
isEmpty,
|
||||||
|
hasCreatureCombatants,
|
||||||
|
canRollAllInitiative,
|
||||||
advanceTurn,
|
advanceTurn,
|
||||||
retreatTurn,
|
retreatTurn,
|
||||||
addCombatant,
|
addCombatant,
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ interface EditFields {
|
|||||||
readonly name?: string;
|
readonly name?: string;
|
||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly maxHp?: number;
|
readonly maxHp?: number;
|
||||||
readonly color?: string;
|
readonly color?: string | null;
|
||||||
readonly icon?: string;
|
readonly icon?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePlayerCharacters() {
|
export function usePlayerCharacters() {
|
||||||
@@ -51,7 +51,13 @@ export function usePlayerCharacters() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const createCharacter = useCallback(
|
const createCharacter = useCallback(
|
||||||
(name: string, ac: number, maxHp: number, color: string, icon: string) => {
|
(
|
||||||
|
name: string,
|
||||||
|
ac: number,
|
||||||
|
maxHp: number,
|
||||||
|
color: string | undefined,
|
||||||
|
icon: string | undefined,
|
||||||
|
) => {
|
||||||
const id = generatePcId();
|
const id = generatePcId();
|
||||||
const result = createPlayerCharacterUseCase(
|
const result = createPlayerCharacterUseCase(
|
||||||
makeStore(),
|
makeStore(),
|
||||||
|
|||||||
101
apps/web/src/hooks/use-side-panel-state.ts
Normal file
101
apps/web/src/hooks/use-side-panel-state.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import type { CreatureId } from "@initiative/domain";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type PanelView =
|
||||||
|
| { mode: "closed" }
|
||||||
|
| { mode: "creature"; creatureId: CreatureId }
|
||||||
|
| { mode: "bulk-import" }
|
||||||
|
| { mode: "source-manager" };
|
||||||
|
|
||||||
|
interface SidePanelState {
|
||||||
|
panelView: PanelView;
|
||||||
|
selectedCreatureId: CreatureId | null;
|
||||||
|
bulkImportMode: boolean;
|
||||||
|
sourceManagerMode: boolean;
|
||||||
|
isRightPanelCollapsed: boolean;
|
||||||
|
pinnedCreatureId: CreatureId | null;
|
||||||
|
isWideDesktop: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidePanelActions {
|
||||||
|
showCreature: (creatureId: CreatureId) => void;
|
||||||
|
showBulkImport: () => void;
|
||||||
|
showSourceManager: () => void;
|
||||||
|
dismissPanel: () => void;
|
||||||
|
toggleCollapse: () => void;
|
||||||
|
togglePin: () => void;
|
||||||
|
unpin: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidePanelState(): SidePanelState & SidePanelActions {
|
||||||
|
const [panelView, setPanelView] = useState<PanelView>({ mode: "closed" });
|
||||||
|
const [isRightPanelCollapsed, setIsRightPanelCollapsed] = useState(false);
|
||||||
|
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [isWideDesktop, setIsWideDesktop] = useState(
|
||||||
|
() => globalThis.matchMedia("(min-width: 1280px)").matches,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = globalThis.matchMedia("(min-width: 1280px)");
|
||||||
|
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
|
||||||
|
mq.addEventListener("change", handler);
|
||||||
|
return () => mq.removeEventListener("change", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedCreatureId =
|
||||||
|
panelView.mode === "creature" ? panelView.creatureId : null;
|
||||||
|
|
||||||
|
const showCreature = useCallback((creatureId: CreatureId) => {
|
||||||
|
setPanelView({ mode: "creature", creatureId });
|
||||||
|
setIsRightPanelCollapsed(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showBulkImport = useCallback(() => {
|
||||||
|
setPanelView({ mode: "bulk-import" });
|
||||||
|
setIsRightPanelCollapsed(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showSourceManager = useCallback(() => {
|
||||||
|
setPanelView({ mode: "source-manager" });
|
||||||
|
setIsRightPanelCollapsed(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismissPanel = useCallback(() => {
|
||||||
|
setPanelView({ mode: "closed" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleCollapse = useCallback(() => {
|
||||||
|
setIsRightPanelCollapsed((f) => !f);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const togglePin = useCallback(() => {
|
||||||
|
if (selectedCreatureId) {
|
||||||
|
setPinnedCreatureId((prev) =>
|
||||||
|
prev === selectedCreatureId ? null : selectedCreatureId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [selectedCreatureId]);
|
||||||
|
|
||||||
|
const unpin = useCallback(() => {
|
||||||
|
setPinnedCreatureId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
panelView,
|
||||||
|
selectedCreatureId,
|
||||||
|
bulkImportMode: panelView.mode === "bulk-import",
|
||||||
|
sourceManagerMode: panelView.mode === "source-manager",
|
||||||
|
isRightPanelCollapsed,
|
||||||
|
pinnedCreatureId,
|
||||||
|
isWideDesktop,
|
||||||
|
showCreature,
|
||||||
|
showBulkImport,
|
||||||
|
showSourceManager,
|
||||||
|
dismissPanel,
|
||||||
|
toggleCollapse,
|
||||||
|
togglePin,
|
||||||
|
unpin,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,6 +15,13 @@ export function savePlayerCharacters(characters: PlayerCharacter[]): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidOptionalMember(
|
||||||
|
value: unknown,
|
||||||
|
valid: ReadonlySet<string>,
|
||||||
|
): boolean {
|
||||||
|
return value === undefined || (typeof value === "string" && valid.has(value));
|
||||||
|
}
|
||||||
|
|
||||||
function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
||||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
||||||
return null;
|
return null;
|
||||||
@@ -35,10 +42,8 @@ function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
|||||||
entry.maxHp < 1
|
entry.maxHp < 1
|
||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
if (typeof entry.color !== "string" || !VALID_PLAYER_COLORS.has(entry.color))
|
if (!isValidOptionalMember(entry.color, VALID_PLAYER_COLORS)) return null;
|
||||||
return null;
|
if (!isValidOptionalMember(entry.icon, VALID_PLAYER_ICONS)) return null;
|
||||||
if (typeof entry.icon !== "string" || !VALID_PLAYER_ICONS.has(entry.icon))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: playerCharacterId(entry.id),
|
id: playerCharacterId(entry.id),
|
||||||
|
|||||||
102
biome.json
102
biome.json
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
"**",
|
"**",
|
||||||
"!**/dist/**",
|
"!**/dist",
|
||||||
"!.claude/**",
|
"!.claude",
|
||||||
"!.specify/**",
|
"!.specify",
|
||||||
"!specs/**",
|
"!specs",
|
||||||
"!coverage/**",
|
"!coverage",
|
||||||
"!.pnpm-store/**"
|
"!.pnpm-store"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"assist": {
|
"assist": {
|
||||||
@@ -21,6 +21,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"css": {
|
||||||
|
"parser": {
|
||||||
|
"cssModules": false,
|
||||||
|
"tailwindDirectives": true
|
||||||
|
}
|
||||||
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"indentStyle": "tab",
|
"indentStyle": "tab",
|
||||||
@@ -30,13 +36,93 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
|
"a11y": {
|
||||||
|
"noNoninteractiveElementInteractions": "error"
|
||||||
|
},
|
||||||
"complexity": {
|
"complexity": {
|
||||||
"noExcessiveCognitiveComplexity": {
|
"noExcessiveCognitiveComplexity": {
|
||||||
"level": "error",
|
"level": "error",
|
||||||
"options": {
|
"options": {
|
||||||
"maxAllowedComplexity": 15
|
"maxAllowedComplexity": 15
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"noUselessStringConcat": "error"
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"noNestedComponentDefinitions": "error",
|
||||||
|
"noReactPropAssignments": "error"
|
||||||
|
},
|
||||||
|
"nursery": {
|
||||||
|
"noConditionalExpect": "error",
|
||||||
|
"noDuplicatedSpreadProps": "error",
|
||||||
|
"noFloatingPromises": "error",
|
||||||
|
"noLeakedRender": "error",
|
||||||
|
"noMisusedPromises": "error",
|
||||||
|
"noNestedPromises": "error",
|
||||||
|
"noReturnAssign": "error",
|
||||||
|
"noScriptUrl": "error",
|
||||||
|
"noShadow": "error",
|
||||||
|
"noUnnecessaryConditions": "error",
|
||||||
|
"noUselessReturn": "error",
|
||||||
|
"useArraySome": "error",
|
||||||
|
"useArraySortCompare": "error",
|
||||||
|
"useAwaitThenable": "error",
|
||||||
|
"useErrorCause": "error",
|
||||||
|
"useExhaustiveSwitchCases": "error",
|
||||||
|
"useFind": "error",
|
||||||
|
"useGlobalThis": "error",
|
||||||
|
"useNullishCoalescing": "error",
|
||||||
|
"useRegexpExec": "error",
|
||||||
|
"useSortedClasses": "error",
|
||||||
|
"useSpread": "error"
|
||||||
|
},
|
||||||
|
"performance": {
|
||||||
|
"noAwaitInLoops": "error",
|
||||||
|
"useTopLevelRegex": "error"
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"noCommonJs": "error",
|
||||||
|
"noDoneCallback": "error",
|
||||||
|
"noExportedImports": "error",
|
||||||
|
"noInferrableTypes": "error",
|
||||||
|
"noNamespace": "error",
|
||||||
|
"noNegationElse": "error",
|
||||||
|
"noNestedTernary": "error",
|
||||||
|
"noParameterAssign": "error",
|
||||||
|
"noSubstr": "error",
|
||||||
|
"noUnusedTemplateLiteral": "error",
|
||||||
|
"noUselessElse": "error",
|
||||||
|
"noYodaExpression": "error",
|
||||||
|
"useAsConstAssertion": "error",
|
||||||
|
"useAtIndex": "error",
|
||||||
|
"useCollapsedElseIf": "error",
|
||||||
|
"useCollapsedIf": "error",
|
||||||
|
"useConsistentBuiltinInstantiation": "error",
|
||||||
|
"useDefaultParameterLast": "error",
|
||||||
|
"useExplicitLengthCheck": "error",
|
||||||
|
"useForOf": "error",
|
||||||
|
"useFragmentSyntax": "error",
|
||||||
|
"useNumberNamespace": "error",
|
||||||
|
"useSelfClosingElements": "error",
|
||||||
|
"useShorthandAssign": "error",
|
||||||
|
"useThrowNewError": "error",
|
||||||
|
"useThrowOnlyError": "error",
|
||||||
|
"useTrimStartEnd": "error"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"noAlert": "error",
|
||||||
|
"noConstantBinaryExpressions": "error",
|
||||||
|
"noDeprecatedImports": "error",
|
||||||
|
"noEvolvingTypes": "error",
|
||||||
|
"noImportCycles": "error",
|
||||||
|
"noReactForwardRef": "error",
|
||||||
|
"noSkippedTests": "error",
|
||||||
|
"noTemplateCurlyInString": "error",
|
||||||
|
"noTsIgnore": "error",
|
||||||
|
"noUnusedExpressions": "error",
|
||||||
|
"noVar": "error",
|
||||||
|
"useAwait": "error",
|
||||||
|
"useErrorMessage": "error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,536 +0,0 @@
|
|||||||
---
|
|
||||||
date: "2026-03-13T14:58:42.882813+00:00"
|
|
||||||
git_commit: 75778884bd1be7d135b2f5ea9b8a8e77a0149f7b
|
|
||||||
branch: main
|
|
||||||
topic: "Declutter Action Bars"
|
|
||||||
tags: [plan, turn-navigation, action-bar, overflow-menu, ux]
|
|
||||||
status: draft
|
|
||||||
---
|
|
||||||
|
|
||||||
# Declutter Action Bars — Implementation Plan
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Reorganize buttons across the top bar (TurnNavigation) and bottom bar (ActionBar) to reduce visual clutter and improve UX. Each bar gets a clear purpose: the top bar is for turn navigation + encounter lifecycle, the bottom bar is for adding combatants + setup actions.
|
|
||||||
|
|
||||||
## Current State Analysis
|
|
||||||
|
|
||||||
**Top bar** (`turn-navigation.tsx`) has 5 buttons + center info:
|
|
||||||
```
|
|
||||||
[ Prev ] | [ R1 Dwarf ] | [ D20 Library Trash ] [ Next ]
|
|
||||||
```
|
|
||||||
The D20 (roll all initiative) and Library (manage sources) buttons are unrelated to turn navigation — they're setup/utility actions that add noise.
|
|
||||||
|
|
||||||
**Bottom bar** (`action-bar.tsx`) has an input, Add button, and 3 icon buttons:
|
|
||||||
```
|
|
||||||
[ + Add combatants... ] [ Add ] [ Users Eye Import ]
|
|
||||||
```
|
|
||||||
The icon cluster (Users, Eye, Import) is cryptic — three ghost icon buttons with no labels, requiring hover to discover purpose. The Eye button opens a separate search dropdown for browsing stat blocks, which duplicates the existing search input.
|
|
||||||
|
|
||||||
### Key Discoveries:
|
|
||||||
- `rollAllInitiativeUseCase` (`packages/application/src/roll-all-initiative-use-case.ts`) applies to combatants with `creatureId` AND no `initiative` set — this defines the conditional visibility logic
|
|
||||||
- `Combatant.initiative` is `number | undefined` and `Combatant.creatureId` is `CreatureId | undefined` (`packages/domain/src/types.ts`)
|
|
||||||
- No existing dropdown/menu UI component — the overflow menu needs a new component
|
|
||||||
- Lucide provides `EllipsisVertical` for the kebab menu trigger
|
|
||||||
- The stat block viewer already has its own search input, results list, and keyboard navigation (`action-bar.tsx:65-236`) — in browse mode, we reuse the main input for this instead
|
|
||||||
|
|
||||||
## Desired End State
|
|
||||||
|
|
||||||
### UI Mockups
|
|
||||||
|
|
||||||
**Top bar (after):**
|
|
||||||
```
|
|
||||||
[ Prev ] [ R1 Dwarf ] [ Trash ] [ Next ]
|
|
||||||
```
|
|
||||||
4 elements. Clean, focused on turn flow + encounter lifecycle.
|
|
||||||
|
|
||||||
**Bottom bar — add mode (default):**
|
|
||||||
```
|
|
||||||
[ + Add combatants... 👁 ] [ Add ] [ D20? ] [ ⋮ ]
|
|
||||||
```
|
|
||||||
The Eye icon sits inside/beside the input as a toggle. D20 appears conditionally. Kebab menu holds infrequent actions.
|
|
||||||
|
|
||||||
**Bottom bar — browse mode (Eye toggled on):**
|
|
||||||
```
|
|
||||||
[ 🔍 Search stat blocks... 👁 ] [ ⋮ ]
|
|
||||||
```
|
|
||||||
The input switches purpose: placeholder changes, typing searches stat blocks instead of adding combatants. The Add button and D20 hide (irrelevant in browse mode). Eye icon stays as the toggle to switch back. Selecting a result opens the stat block panel and exits browse mode.
|
|
||||||
|
|
||||||
**Overflow menu (⋮ clicked):**
|
|
||||||
```
|
|
||||||
┌──────────────────────┐
|
|
||||||
│ 👥 Player Characters │
|
|
||||||
│ 📚 Manage Sources │
|
|
||||||
│ 📥 Bulk Import │
|
|
||||||
└──────────────────────┘
|
|
||||||
```
|
|
||||||
Labeled items with icons — discoverable without hover.
|
|
||||||
|
|
||||||
### Key Discoveries:
|
|
||||||
- `sourceManagerOpen` state lives in App.tsx:116 — the overflow menu's "Manage Sources" item needs the same toggle callback
|
|
||||||
- The stat block viewer state (viewerOpen, viewerQuery, viewerResults, viewerIndex) in action-bar.tsx:66-71 gets replaced by a `browseMode` boolean that repurposes the main input
|
|
||||||
- The viewer's separate input, dropdown, and keyboard handling (action-bar.tsx:188-248) can be removed — browse mode reuses the existing input and suggestion dropdown infrastructure
|
|
||||||
|
|
||||||
## What We're NOT Doing
|
|
||||||
|
|
||||||
- Changing domain logic or use cases
|
|
||||||
- Modifying ConfirmButton behavior
|
|
||||||
- Changing the stat block panel itself
|
|
||||||
- Altering animation logic (useActionBarAnimation)
|
|
||||||
- Modifying combatant row buttons
|
|
||||||
- Changing how SourceManager works (just moving where the trigger lives)
|
|
||||||
|
|
||||||
## Implementation Approach
|
|
||||||
|
|
||||||
Four phases, each independently testable. Phase 1 simplifies the top bar (pure removal). Phase 2 adds the overflow menu component. Phase 3 reworks the ActionBar (browse toggle + conditional D20 + overflow integration). Phase 4 wires everything together in App.tsx.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1: Simplify TurnNavigation
|
|
||||||
|
|
||||||
### Overview
|
|
||||||
Strip TurnNavigation down to just turn controls + clear encounter. Remove Roll All Initiative and Manage Sources buttons and their associated props.
|
|
||||||
|
|
||||||
### Changes Required:
|
|
||||||
|
|
||||||
#### [x] 1. Update TurnNavigation component
|
|
||||||
**File**: `apps/web/src/components/turn-navigation.tsx`
|
|
||||||
**Changes**:
|
|
||||||
- Remove `onRollAllInitiative` and `onOpenSourceManager` from props interface
|
|
||||||
- Remove the D20 button (lines 53-62)
|
|
||||||
- Remove the Library button (lines 63-72)
|
|
||||||
- Remove the inner `gap-0` div wrapper (lines 52, 80) since only the ConfirmButton remains
|
|
||||||
- Remove unused imports: `Library` from lucide-react, `D20Icon`
|
|
||||||
- Adjust layout: ConfirmButton + Next button grouped with `gap-3`
|
|
||||||
|
|
||||||
Result:
|
|
||||||
```tsx
|
|
||||||
interface TurnNavigationProps {
|
|
||||||
encounter: Encounter;
|
|
||||||
onAdvanceTurn: () => void;
|
|
||||||
onRetreatTurn: () => void;
|
|
||||||
onClearEncounter: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layout becomes:
|
|
||||||
// [ Prev ] | [ R1 Name ] | [ Trash ] [ Next ]
|
|
||||||
```
|
|
||||||
|
|
||||||
#### [x] 2. Update TurnNavigation usage in App.tsx
|
|
||||||
**File**: `apps/web/src/App.tsx`
|
|
||||||
**Changes**:
|
|
||||||
- Remove `onRollAllInitiative` and `onOpenSourceManager` props from the `<TurnNavigation>` call (lines 256-257)
|
|
||||||
|
|
||||||
### Success Criteria:
|
|
||||||
|
|
||||||
#### Automated Verification:
|
|
||||||
- [x] `pnpm check` passes (typecheck catches removed props, lint catches unused imports)
|
|
||||||
|
|
||||||
#### Manual Verification:
|
|
||||||
- [ ] Top bar shows only: Prev, round badge + name, trash, Next
|
|
||||||
- [ ] Prev/Next/Clear buttons still work as before
|
|
||||||
- [ ] Top bar animation (slide in/out) unchanged
|
|
||||||
|
|
||||||
**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Create Overflow Menu Component
|
|
||||||
|
|
||||||
### Overview
|
|
||||||
Build a reusable overflow menu (kebab menu) component with click-outside and Escape handling, following the same patterns as ConfirmButton and the existing viewer dropdown.
|
|
||||||
|
|
||||||
### Changes Required:
|
|
||||||
|
|
||||||
#### [x] 1. Create OverflowMenu component
|
|
||||||
**File**: `apps/web/src/components/ui/overflow-menu.tsx` (new file)
|
|
||||||
**Changes**: Create a dropdown menu triggered by an EllipsisVertical icon button. Features:
|
|
||||||
- Toggle open/close on button click
|
|
||||||
- Close on click outside (document mousedown listener, same pattern as confirm-button.tsx:44-67)
|
|
||||||
- Close on Escape key
|
|
||||||
- Renders above the trigger (bottom-full positioning, same as action-bar suggestion dropdown)
|
|
||||||
- Each item: icon + label, full-width clickable row
|
|
||||||
- Clicking an item calls its action and closes the menu
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { EllipsisVertical } from "lucide-react";
|
|
||||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
||||||
import { Button } from "./button";
|
|
||||||
|
|
||||||
export interface OverflowMenuItem {
|
|
||||||
readonly icon: ReactNode;
|
|
||||||
readonly label: string;
|
|
||||||
readonly onClick: () => void;
|
|
||||||
readonly disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OverflowMenuProps {
|
|
||||||
readonly items: readonly OverflowMenuItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OverflowMenu({ items }: OverflowMenuProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
function handleMouseDown(e: MouseEvent) {
|
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") setOpen(false);
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleMouseDown);
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", handleMouseDown);
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} className="relative">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="text-muted-foreground hover:text-hover-neutral"
|
|
||||||
onClick={() => setOpen((o) => !o)}
|
|
||||||
aria-label="More actions"
|
|
||||||
title="More actions"
|
|
||||||
>
|
|
||||||
<EllipsisVertical className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
{open && (
|
|
||||||
<div className="absolute bottom-full right-0 z-50 mb-1 min-w-48 rounded-md border border-border bg-card py-1 shadow-lg">
|
|
||||||
{items.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.label}
|
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-hover-neutral-bg disabled:pointer-events-none disabled:opacity-50"
|
|
||||||
disabled={item.disabled}
|
|
||||||
onClick={() => {
|
|
||||||
item.onClick();
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.icon}
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Success Criteria:
|
|
||||||
|
|
||||||
#### Automated Verification:
|
|
||||||
- [x] `pnpm check` passes (new file compiles, no unused exports yet — will be used in phase 3)
|
|
||||||
|
|
||||||
#### Manual Verification:
|
|
||||||
- [ ] N/A — component not yet wired into the UI
|
|
||||||
|
|
||||||
**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: Rework ActionBar
|
|
||||||
|
|
||||||
### Overview
|
|
||||||
Replace the icon button cluster with: (1) an Eye toggle on the input that switches between add mode and browse mode, (2) a conditional Roll All Initiative button, and (3) the overflow menu for infrequent actions.
|
|
||||||
|
|
||||||
### Changes Required:
|
|
||||||
|
|
||||||
#### [x] 1. Update ActionBarProps
|
|
||||||
**File**: `apps/web/src/components/action-bar.tsx`
|
|
||||||
**Changes**: Add new props, keep existing ones needed for overflow menu items:
|
|
||||||
```tsx
|
|
||||||
interface ActionBarProps {
|
|
||||||
// ... existing props stay ...
|
|
||||||
onRollAllInitiative?: () => void; // new — moved from top bar
|
|
||||||
showRollAllInitiative?: boolean; // new — conditional visibility
|
|
||||||
onOpenSourceManager?: () => void; // new — moved from top bar
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### [x] 2. Add browse mode state
|
|
||||||
**File**: `apps/web/src/components/action-bar.tsx`
|
|
||||||
**Changes**: Replace the separate viewer state (viewerOpen, viewerQuery, viewerResults, viewerIndex, viewerRef, viewerInputRef — lines 66-71) with a single `browseMode` boolean:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const [browseMode, setBrowseMode] = useState(false);
|
|
||||||
```
|
|
||||||
|
|
||||||
Remove all viewer-specific state variables and handlers:
|
|
||||||
- `viewerOpen`, `viewerQuery`, `viewerResults`, `viewerIndex` (lines 66-69)
|
|
||||||
- `viewerRef`, `viewerInputRef` (lines 70-71)
|
|
||||||
- `openViewer`, `closeViewer` (lines 189-202)
|
|
||||||
- `handleViewerQueryChange`, `handleViewerSelect`, `handleViewerKeyDown` (lines 204-236)
|
|
||||||
- The viewer click-outside effect (lines 239-248)
|
|
||||||
|
|
||||||
#### [x] 3. Rework the input area with Eye toggle
|
|
||||||
**File**: `apps/web/src/components/action-bar.tsx`
|
|
||||||
**Changes**: Add an Eye icon button inside the input wrapper that toggles browse mode. When browse mode is active:
|
|
||||||
- Placeholder changes to "Search stat blocks..."
|
|
||||||
- Typing calls `bestiarySearch` but selecting a result calls `onViewStatBlock` instead of queuing/adding
|
|
||||||
- The suggestion dropdown shows results but clicking opens stat block panel instead of adding
|
|
||||||
- Add button and custom fields (Init/AC/MaxHP) are hidden
|
|
||||||
- D20 button is hidden
|
|
||||||
|
|
||||||
When toggling browse mode off, clear the input and suggestions.
|
|
||||||
|
|
||||||
The Eye icon sits to the right of the input inside the `relative flex-1` wrapper:
|
|
||||||
```tsx
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={nameInput}
|
|
||||||
onChange={(e) => handleNameChange(e.target.value)}
|
|
||||||
onKeyDown={browseMode ? handleBrowseKeyDown : handleKeyDown}
|
|
||||||
placeholder={browseMode ? "Search stat blocks..." : "+ Add combatants"}
|
|
||||||
className="max-w-xs pr-8"
|
|
||||||
autoFocus={autoFocus}
|
|
||||||
/>
|
|
||||||
{bestiaryLoaded && onViewStatBlock && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
|
|
||||||
browseMode && "text-accent",
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
setBrowseMode((m) => !m);
|
|
||||||
setNameInput("");
|
|
||||||
setSuggestions([]);
|
|
||||||
setPcMatches([]);
|
|
||||||
setQueued(null);
|
|
||||||
setSuggestionIndex(-1);
|
|
||||||
}}
|
|
||||||
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
|
|
||||||
aria-label={browseMode ? "Switch to add mode" : "Browse stat blocks"}
|
|
||||||
>
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* suggestion dropdown — behavior changes based on browseMode */}
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Import `cn` from `../../lib/utils` (already used by other components).
|
|
||||||
|
|
||||||
#### [x] 4. Update suggestion dropdown for browse mode
|
|
||||||
**File**: `apps/web/src/components/action-bar.tsx`
|
|
||||||
**Changes**: In browse mode, the suggestion dropdown behaves differently:
|
|
||||||
- No "Add as custom" row at the top
|
|
||||||
- No player character matches section
|
|
||||||
- No queuing (plus/minus/confirm) — clicking a result calls `onViewStatBlock` and exits browse mode
|
|
||||||
- Keyboard Enter on a highlighted result calls `onViewStatBlock` and exits browse mode
|
|
||||||
|
|
||||||
Add a `handleBrowseKeyDown` handler:
|
|
||||||
```tsx
|
|
||||||
const handleBrowseKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
setBrowseMode(false);
|
|
||||||
setNameInput("");
|
|
||||||
setSuggestions([]);
|
|
||||||
setSuggestionIndex(-1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (suggestions.length === 0) return;
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
|
|
||||||
} else if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
|
||||||
} else if (e.key === "Enter" && suggestionIndex >= 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
onViewStatBlock?.(suggestions[suggestionIndex]);
|
|
||||||
setBrowseMode(false);
|
|
||||||
setNameInput("");
|
|
||||||
setSuggestions([]);
|
|
||||||
setSuggestionIndex(-1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
In the suggestion dropdown JSX, conditionally render based on `browseMode`:
|
|
||||||
- Browse mode: simple list of creature results, click → `onViewStatBlock` + exit browse mode
|
|
||||||
- Add mode: existing behavior (custom row, PC matches, queuing)
|
|
||||||
|
|
||||||
#### [x] 5. Replace icon button cluster with D20 + overflow menu
|
|
||||||
**File**: `apps/web/src/components/action-bar.tsx`
|
|
||||||
**Changes**: Replace the `div.flex.items-center.gap-0` block (lines 443-529) containing Users, Eye, and Import buttons with:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
{!browseMode && (
|
|
||||||
<>
|
|
||||||
<Button type="submit" size="sm">
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
{showRollAllInitiative && onRollAllInitiative && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-muted-foreground hover:text-hover-action"
|
|
||||||
onClick={onRollAllInitiative}
|
|
||||||
title="Roll all initiative"
|
|
||||||
aria-label="Roll all initiative"
|
|
||||||
>
|
|
||||||
<D20Icon className="h-6 w-6" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<OverflowMenu items={overflowItems} />
|
|
||||||
```
|
|
||||||
|
|
||||||
Build the `overflowItems` array from props:
|
|
||||||
```tsx
|
|
||||||
const overflowItems: OverflowMenuItem[] = [];
|
|
||||||
if (onManagePlayers) {
|
|
||||||
overflowItems.push({
|
|
||||||
icon: <Users className="h-4 w-4" />,
|
|
||||||
label: "Player Characters",
|
|
||||||
onClick: onManagePlayers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (onOpenSourceManager) {
|
|
||||||
overflowItems.push({
|
|
||||||
icon: <Library className="h-4 w-4" />,
|
|
||||||
label: "Manage Sources",
|
|
||||||
onClick: onOpenSourceManager,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (bestiaryLoaded && onBulkImport) {
|
|
||||||
overflowItems.push({
|
|
||||||
icon: <Import className="h-4 w-4" />,
|
|
||||||
label: "Bulk Import",
|
|
||||||
onClick: onBulkImport,
|
|
||||||
disabled: bulkImportDisabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### [x] 6. Clean up imports
|
|
||||||
**File**: `apps/web/src/components/action-bar.tsx`
|
|
||||||
**Changes**:
|
|
||||||
- Add imports: `D20Icon`, `OverflowMenu` + `OverflowMenuItem`, `Library` from lucide-react, `cn` from utils
|
|
||||||
- Remove imports that are no longer needed after removing the standalone viewer: check which of `Eye`, `Import`, `Users` are still used (Eye stays for the toggle, Users and Import stay for overflow item icons, Library is new)
|
|
||||||
- The `Check`, `Minus`, `Plus` imports stay (used in queuing UI)
|
|
||||||
|
|
||||||
### Success Criteria:
|
|
||||||
|
|
||||||
#### Automated Verification:
|
|
||||||
- [x] `pnpm check` passes
|
|
||||||
|
|
||||||
#### Manual Verification:
|
|
||||||
- [ ] Bottom bar shows: input with Eye toggle, Add button, (conditional D20), kebab menu
|
|
||||||
- [ ] Eye toggle switches input between "add" and "browse" modes
|
|
||||||
- [ ] In browse mode: typing shows bestiary results, clicking one opens stat block panel, exits browse mode
|
|
||||||
- [ ] In browse mode: Add button and D20 are hidden, overflow menu stays visible
|
|
||||||
- [ ] In add mode: existing behavior works (search, queue, custom fields, PC matches)
|
|
||||||
- [ ] Overflow menu opens/closes on click, closes on Escape and click-outside
|
|
||||||
- [ ] Overflow menu items (Player Characters, Manage Sources, Bulk Import) trigger correct actions
|
|
||||||
- [ ] D20 button appears only when bestiary combatants lack initiative, disappears when all have values
|
|
||||||
|
|
||||||
**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: Wire Up App.tsx
|
|
||||||
|
|
||||||
### Overview
|
|
||||||
Pass the new props to ActionBar — roll all initiative handler, conditional visibility flag, and source manager toggle. Remove the now-unused `onOpenSourceManager` callback from the TurnNavigation call (already removed in Phase 1) and ensure sourceManagerOpen toggle is routed through the overflow menu.
|
|
||||||
|
|
||||||
### Changes Required:
|
|
||||||
|
|
||||||
#### [x] 1. Compute showRollAllInitiative flag
|
|
||||||
**File**: `apps/web/src/App.tsx`
|
|
||||||
**Changes**: Add a derived boolean that checks if any combatant with a `creatureId` lacks an `initiative` value:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const showRollAllInitiative = encounter.combatants.some(
|
|
||||||
(c) => c.creatureId != null && c.initiative == null,
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
Place this near `const isEmpty = ...` (line 241).
|
|
||||||
|
|
||||||
#### [x] 2. Pass new props to both ActionBar instances
|
|
||||||
**File**: `apps/web/src/App.tsx`
|
|
||||||
**Changes**: Add to both `<ActionBar>` calls (empty state at ~line 269 and populated state at ~line 328):
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<ActionBar
|
|
||||||
// ... existing props ...
|
|
||||||
onRollAllInitiative={handleRollAllInitiative}
|
|
||||||
showRollAllInitiative={showRollAllInitiative}
|
|
||||||
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### [x] 3. Remove stale code
|
|
||||||
**File**: `apps/web/src/App.tsx`
|
|
||||||
**Changes**:
|
|
||||||
- The `onRollAllInitiative` and `onOpenSourceManager` props were already removed from `<TurnNavigation>` in Phase 1 — verify no references remain
|
|
||||||
- Verify `sourceManagerOpen` state and the `<SourceManager>` rendering block (lines 287-291) still work correctly — the SourceManager inline panel is still toggled by the same state, just from a different trigger location
|
|
||||||
|
|
||||||
### Success Criteria:
|
|
||||||
|
|
||||||
#### Automated Verification:
|
|
||||||
- [x] `pnpm check` passes
|
|
||||||
|
|
||||||
#### Manual Verification:
|
|
||||||
- [ ] Top bar: only Prev, round badge + name, trash, Next — no D20 or Library buttons
|
|
||||||
- [ ] Bottom bar: input with Eye toggle, Add, conditional D20, overflow menu
|
|
||||||
- [ ] Roll All Initiative (D20 in bottom bar): visible when bestiary creatures lack initiative, hidden after rolling
|
|
||||||
- [ ] Overflow → Player Characters: opens player management modal
|
|
||||||
- [ ] Overflow → Manage Sources: toggles source manager panel (same as before, just different trigger)
|
|
||||||
- [ ] Overflow → Bulk Import: opens bulk import mode
|
|
||||||
- [ ] Browse mode (Eye toggle): search stat blocks without adding, selecting opens panel
|
|
||||||
- [ ] Clear encounter (top bar trash): still works with two-click confirmation
|
|
||||||
- [ ] All animations (bar transitions) unchanged
|
|
||||||
- [ ] Empty state: ActionBar centered with all functionality accessible
|
|
||||||
|
|
||||||
**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
### Unit Tests:
|
|
||||||
- No domain/application changes — existing tests should pass unchanged
|
|
||||||
- `pnpm check` covers typecheck + lint + existing test suite
|
|
||||||
|
|
||||||
### Manual Testing Steps:
|
|
||||||
1. Start with empty encounter — verify ActionBar is centered with Eye toggle and overflow menu
|
|
||||||
2. Add a bestiary creature — verify D20 appears in bottom bar, top bar slides in with just 4 elements
|
|
||||||
3. Click D20 → initiative rolls → D20 disappears from bottom bar
|
|
||||||
4. Toggle Eye → input switches to browse mode → search and select → stat block opens → exits browse mode
|
|
||||||
5. Open overflow menu → click each item → verify correct modal/panel opens
|
|
||||||
6. Click trash in top bar → confirm → encounter clears, back to empty state
|
|
||||||
7. Add custom creature (no creatureId) → D20 should not appear (no bestiary creatures)
|
|
||||||
8. Add mix of custom + bestiary creatures → D20 visible → roll all → D20 hidden
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
None — this is a pure UI reorganization with no new data fetching, state management changes, or rendering overhead. The `showRollAllInitiative` computation is a simple `.some()` over the combatant array, which is negligible.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- Research: `docs/agents/research/2026-03-13-action-bars-and-buttons.md`
|
|
||||||
- Top bar: `apps/web/src/components/turn-navigation.tsx`
|
|
||||||
- Bottom bar: `apps/web/src/components/action-bar.tsx`
|
|
||||||
- App layout: `apps/web/src/App.tsx`
|
|
||||||
- Button: `apps/web/src/components/ui/button.tsx`
|
|
||||||
- ConfirmButton: `apps/web/src/components/ui/confirm-button.tsx`
|
|
||||||
- Roll all use case: `packages/application/src/roll-all-initiative-use-case.ts`
|
|
||||||
- Combatant type: `packages/domain/src/types.ts`
|
|
||||||
13
package.json
13
package.json
@@ -1,12 +1,19 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.6.0",
|
"packageManager": "pnpm@10.6.0",
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"undici": ">=7.24.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.0.0",
|
"@biomejs/biome": "2.4.7",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"jscpd": "^4.0.8",
|
"jscpd": "^4.0.8",
|
||||||
"knip": "^5.85.0",
|
"knip": "^5.85.0",
|
||||||
"lefthook": "^1.11.0",
|
"lefthook": "^1.11.0",
|
||||||
|
"oxlint": "^1.55.0",
|
||||||
|
"oxlint-tsgolint": "^0.16.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
},
|
},
|
||||||
@@ -21,6 +28,8 @@
|
|||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"knip": "knip",
|
"knip": "knip",
|
||||||
"jscpd": "jscpd",
|
"jscpd": "jscpd",
|
||||||
"check": "pnpm audit --audit-level=high && knip && biome check . && tsc --build && vitest run && jscpd"
|
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
|
||||||
|
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
||||||
|
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && tsc --build && vitest run && jscpd"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
54
packages/application/src/__tests__/helpers.ts
Normal file
54
packages/application/src/__tests__/helpers.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 1–20), 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
388
packages/application/src/__tests__/use-cases.test.ts
Normal file
388
packages/application/src/__tests__/use-cases.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,8 +13,8 @@ export function createPlayerCharacterUseCase(
|
|||||||
name: string,
|
name: string,
|
||||||
ac: number,
|
ac: number,
|
||||||
maxHp: number,
|
maxHp: number,
|
||||||
color: string,
|
color: string | undefined,
|
||||||
icon: string,
|
icon: string | undefined,
|
||||||
): DomainEvent[] | DomainError {
|
): DomainEvent[] | DomainError {
|
||||||
const characters = store.getAll();
|
const characters = store.getAll();
|
||||||
const result = createPlayerCharacter(
|
const result = createPlayerCharacter(
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ interface EditFields {
|
|||||||
readonly name?: string;
|
readonly name?: string;
|
||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly maxHp?: number;
|
readonly maxHp?: number;
|
||||||
readonly color?: string;
|
readonly color?: string | null;
|
||||||
readonly icon?: string;
|
readonly icon?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function editPlayerCharacterUseCase(
|
export function editPlayerCharacterUseCase(
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ export type {
|
|||||||
} from "./ports.js";
|
} from "./ports.js";
|
||||||
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
||||||
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
||||||
export { rollAllInitiativeUseCase } from "./roll-all-initiative-use-case.js";
|
export {
|
||||||
|
type RollAllResult,
|
||||||
|
rollAllInitiativeUseCase,
|
||||||
|
} from "./roll-all-initiative-use-case.js";
|
||||||
export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
|
export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
|
||||||
export { setAcUseCase } from "./set-ac-use-case.js";
|
export { setAcUseCase } from "./set-ac-use-case.js";
|
||||||
export { setHpUseCase } from "./set-hp-use-case.js";
|
export { setHpUseCase } from "./set-hp-use-case.js";
|
||||||
|
|||||||
@@ -10,20 +10,29 @@ import {
|
|||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
|
export interface RollAllResult {
|
||||||
|
events: DomainEvent[];
|
||||||
|
skippedNoSource: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function rollAllInitiativeUseCase(
|
export function rollAllInitiativeUseCase(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
rollDice: () => number,
|
rollDice: () => number,
|
||||||
getCreature: (id: CreatureId) => Creature | undefined,
|
getCreature: (id: CreatureId) => Creature | undefined,
|
||||||
): DomainEvent[] | DomainError {
|
): RollAllResult | DomainError {
|
||||||
let encounter = store.get();
|
let encounter = store.get();
|
||||||
const allEvents: DomainEvent[] = [];
|
const allEvents: DomainEvent[] = [];
|
||||||
|
let skippedNoSource = 0;
|
||||||
|
|
||||||
for (const combatant of encounter.combatants) {
|
for (const combatant of encounter.combatants) {
|
||||||
if (!combatant.creatureId) continue;
|
if (!combatant.creatureId) continue;
|
||||||
if (combatant.initiative !== undefined) continue;
|
if (combatant.initiative !== undefined) continue;
|
||||||
|
|
||||||
const creature = getCreature(combatant.creatureId);
|
const creature = getCreature(combatant.creatureId);
|
||||||
if (!creature) continue;
|
if (!creature) {
|
||||||
|
skippedNoSource++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const { modifier } = calculateInitiative({
|
const { modifier } = calculateInitiative({
|
||||||
dexScore: creature.abilities.dex,
|
dexScore: creature.abilities.dex,
|
||||||
@@ -47,5 +56,5 @@ export function rollAllInitiativeUseCase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
store.save(encounter);
|
store.save(encounter);
|
||||||
return allEvents;
|
return { events: allEvents, skippedNoSource };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { addCombatant } from "../add-combatant.js";
|
import { addCombatant } from "../add-combatant.js";
|
||||||
import type { Combatant, Encounter } from "../types.js";
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
import { combatantId, isDomainError } from "../types.js";
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
@@ -112,20 +113,14 @@ describe("addCombatant", () => {
|
|||||||
const e = enc([A, B]);
|
const e = enc([A, B]);
|
||||||
const result = addCombatant(e, combatantId("x"), "");
|
const result = addCombatant(e, combatantId("x"), "");
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-name");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-name");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("scenario 6: whitespace-only name returns error", () => {
|
it("scenario 6: whitespace-only name returns error", () => {
|
||||||
const e = enc([A, B]);
|
const e = enc([A, B]);
|
||||||
const result = addCombatant(e, combatantId("x"), " ");
|
const result = addCombatant(e, combatantId("x"), " ");
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-name");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-name");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -146,12 +141,10 @@ describe("addCombatant", () => {
|
|||||||
for (const e of scenarios) {
|
for (const e of scenarios) {
|
||||||
const result = successResult(e, "new", "New");
|
const result = successResult(e, "new", "New");
|
||||||
const { combatants, activeIndex } = result.encounter;
|
const { combatants, activeIndex } = result.encounter;
|
||||||
if (combatants.length > 0) {
|
// After adding a combatant, list is always non-empty
|
||||||
|
expect(combatants.length).toBeGreaterThan(0);
|
||||||
expect(activeIndex).toBeGreaterThanOrEqual(0);
|
expect(activeIndex).toBeGreaterThanOrEqual(0);
|
||||||
expect(activeIndex).toBeLessThan(combatants.length);
|
expect(activeIndex).toBeLessThan(combatants.length);
|
||||||
} else {
|
|
||||||
expect(activeIndex).toBe(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,7 +181,7 @@ describe("addCombatant", () => {
|
|||||||
it("INV-7: new combatant is always appended at the end", () => {
|
it("INV-7: new combatant is always appended at the end", () => {
|
||||||
const e = enc([A, B]);
|
const e = enc([A, B]);
|
||||||
const { encounter } = successResult(e, "C", "C");
|
const { encounter } = successResult(e, "C", "C");
|
||||||
expect(encounter.combatants[encounter.combatants.length - 1]).toEqual({
|
expect(encounter.combatants.at(-1)).toEqual({
|
||||||
id: combatantId("C"),
|
id: combatantId("C"),
|
||||||
name: "C",
|
name: "C",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { adjustHp } from "../adjust-hp.js";
|
import { adjustHp } from "../adjust-hp.js";
|
||||||
import type { Combatant, Encounter } from "../types.js";
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
import { combatantId, isDomainError } from "../types.js";
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
function makeCombatant(
|
function makeCombatant(
|
||||||
name: string,
|
name: string,
|
||||||
@@ -101,37 +102,25 @@ describe("adjustHp", () => {
|
|||||||
it("returns error for nonexistent combatant", () => {
|
it("returns error for nonexistent combatant", () => {
|
||||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||||
const result = adjustHp(e, combatantId("Z"), -1);
|
const result = adjustHp(e, combatantId("Z"), -1);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "combatant-not-found");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("combatant-not-found");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error when combatant has no HP tracking", () => {
|
it("returns error when combatant has no HP tracking", () => {
|
||||||
const e = enc([makeCombatant("A")]);
|
const e = enc([makeCombatant("A")]);
|
||||||
const result = adjustHp(e, combatantId("A"), -1);
|
const result = adjustHp(e, combatantId("A"), -1);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "no-hp-tracking");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("no-hp-tracking");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error for zero delta", () => {
|
it("returns error for zero delta", () => {
|
||||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||||
const result = adjustHp(e, combatantId("A"), 0);
|
const result = adjustHp(e, combatantId("A"), 0);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "zero-delta");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("zero-delta");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error for non-integer delta", () => {
|
it("returns error for non-integer delta", () => {
|
||||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||||
const result = adjustHp(e, combatantId("A"), 1.5);
|
const result = adjustHp(e, combatantId("A"), 1.5);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-delta");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-delta");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
createEncounter,
|
createEncounter,
|
||||||
type Encounter,
|
type Encounter,
|
||||||
} from "../types.js";
|
} from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
@@ -150,10 +151,7 @@ describe("advanceTurn", () => {
|
|||||||
};
|
};
|
||||||
const result = advanceTurn(enc);
|
const result = advanceTurn(enc);
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-encounter");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-encounter");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("scenario 8: three advances on [A,B,C] completes a full round cycle", () => {
|
it("scenario 8: three advances on [A,B,C] completes a full round cycle", () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createPlayerCharacter } from "../create-player-character.js";
|
|||||||
import type { PlayerCharacter } from "../player-character-types.js";
|
import type { PlayerCharacter } from "../player-character-types.js";
|
||||||
import { playerCharacterId } from "../player-character-types.js";
|
import { playerCharacterId } from "../player-character-types.js";
|
||||||
import { isDomainError } from "../types.js";
|
import { isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
const id = playerCharacterId("pc-1");
|
const id = playerCharacterId("pc-1");
|
||||||
|
|
||||||
@@ -80,10 +81,7 @@ describe("createPlayerCharacter", () => {
|
|||||||
|
|
||||||
it("rejects empty name", () => {
|
it("rejects empty name", () => {
|
||||||
const result = createPlayerCharacter([], id, "", 10, 50, "blue", "sword");
|
const result = createPlayerCharacter([], id, "", 10, 50, "blue", "sword");
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-name");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-name");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects whitespace-only name", () => {
|
it("rejects whitespace-only name", () => {
|
||||||
@@ -96,10 +94,7 @@ describe("createPlayerCharacter", () => {
|
|||||||
"blue",
|
"blue",
|
||||||
"sword",
|
"sword",
|
||||||
);
|
);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-name");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-name");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects negative AC", () => {
|
it("rejects negative AC", () => {
|
||||||
@@ -112,10 +107,7 @@ describe("createPlayerCharacter", () => {
|
|||||||
"blue",
|
"blue",
|
||||||
"sword",
|
"sword",
|
||||||
);
|
);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-ac");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-ac");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects non-integer AC", () => {
|
it("rejects non-integer AC", () => {
|
||||||
@@ -128,10 +120,7 @@ describe("createPlayerCharacter", () => {
|
|||||||
"blue",
|
"blue",
|
||||||
"sword",
|
"sword",
|
||||||
);
|
);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-ac");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-ac");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows AC of 0", () => {
|
it("allows AC of 0", () => {
|
||||||
@@ -149,10 +138,7 @@ describe("createPlayerCharacter", () => {
|
|||||||
"blue",
|
"blue",
|
||||||
"sword",
|
"sword",
|
||||||
);
|
);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-max-hp");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-max-hp");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects negative maxHp", () => {
|
it("rejects negative maxHp", () => {
|
||||||
@@ -165,10 +151,7 @@ describe("createPlayerCharacter", () => {
|
|||||||
"blue",
|
"blue",
|
||||||
"sword",
|
"sword",
|
||||||
);
|
);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-max-hp");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-max-hp");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects non-integer maxHp", () => {
|
it("rejects non-integer maxHp", () => {
|
||||||
@@ -181,10 +164,7 @@ describe("createPlayerCharacter", () => {
|
|||||||
"blue",
|
"blue",
|
||||||
"sword",
|
"sword",
|
||||||
);
|
);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-max-hp");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-max-hp");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid color", () => {
|
it("rejects invalid color", () => {
|
||||||
@@ -197,10 +177,7 @@ describe("createPlayerCharacter", () => {
|
|||||||
"neon",
|
"neon",
|
||||||
"sword",
|
"sword",
|
||||||
);
|
);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-color");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-color");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid icon", () => {
|
it("rejects invalid icon", () => {
|
||||||
@@ -213,10 +190,50 @@ describe("createPlayerCharacter", () => {
|
|||||||
"blue",
|
"blue",
|
||||||
"banana",
|
"banana",
|
||||||
);
|
);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-icon");
|
||||||
if (isDomainError(result)) {
|
});
|
||||||
expect(result.code).toBe("invalid-icon");
|
|
||||||
}
|
it("allows undefined color", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
undefined,
|
||||||
|
"sword",
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].color).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows undefined icon", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
"blue",
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].icon).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows both color and icon undefined", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].color).toBeUndefined();
|
||||||
|
expect(result.characters[0].icon).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits exactly one event on success", () => {
|
it("emits exactly one event on success", () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { deletePlayerCharacter } from "../delete-player-character.js";
|
|||||||
import type { PlayerCharacter } from "../player-character-types.js";
|
import type { PlayerCharacter } from "../player-character-types.js";
|
||||||
import { playerCharacterId } from "../player-character-types.js";
|
import { playerCharacterId } from "../player-character-types.js";
|
||||||
import { isDomainError } from "../types.js";
|
import { isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
const id1 = playerCharacterId("pc-1");
|
const id1 = playerCharacterId("pc-1");
|
||||||
const id2 = playerCharacterId("pc-2");
|
const id2 = playerCharacterId("pc-2");
|
||||||
@@ -28,10 +29,7 @@ describe("deletePlayerCharacter", () => {
|
|||||||
|
|
||||||
it("returns error for not-found id", () => {
|
it("returns error for not-found id", () => {
|
||||||
const result = deletePlayerCharacter([makePC()], id2);
|
const result = deletePlayerCharacter([makePC()], id2);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "player-character-not-found");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("player-character-not-found");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits PlayerCharacterDeleted event", () => {
|
it("emits PlayerCharacterDeleted event", () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { editCombatant } from "../edit-combatant.js";
|
import { editCombatant } from "../edit-combatant.js";
|
||||||
import type { Combatant, Encounter } from "../types.js";
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
import { combatantId, isDomainError } from "../types.js";
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
@@ -124,40 +125,28 @@ describe("editCombatant", () => {
|
|||||||
const e = enc([Alice, Bob]);
|
const e = enc([Alice, Bob]);
|
||||||
const result = editCombatant(e, combatantId("nonexistent"), "NewName");
|
const result = editCombatant(e, combatantId("nonexistent"), "NewName");
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "combatant-not-found");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("combatant-not-found");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("empty name returns invalid-name error", () => {
|
it("empty name returns invalid-name error", () => {
|
||||||
const e = enc([Alice, Bob]);
|
const e = enc([Alice, Bob]);
|
||||||
const result = editCombatant(e, combatantId("Alice"), "");
|
const result = editCombatant(e, combatantId("Alice"), "");
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-name");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-name");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("whitespace-only name returns invalid-name error", () => {
|
it("whitespace-only name returns invalid-name error", () => {
|
||||||
const e = enc([Alice, Bob]);
|
const e = enc([Alice, Bob]);
|
||||||
const result = editCombatant(e, combatantId("Alice"), " ");
|
const result = editCombatant(e, combatantId("Alice"), " ");
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-name");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-name");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("empty encounter returns combatant-not-found for any id", () => {
|
it("empty encounter returns combatant-not-found for any id", () => {
|
||||||
const e = enc([]);
|
const e = enc([]);
|
||||||
const result = editCombatant(e, combatantId("any"), "Name");
|
const result = editCombatant(e, combatantId("any"), "Name");
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "combatant-not-found");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("combatant-not-found");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { editPlayerCharacter } from "../edit-player-character.js";
|
|||||||
import type { PlayerCharacter } from "../player-character-types.js";
|
import type { PlayerCharacter } from "../player-character-types.js";
|
||||||
import { playerCharacterId } from "../player-character-types.js";
|
import { playerCharacterId } from "../player-character-types.js";
|
||||||
import { isDomainError } from "../types.js";
|
import { isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
const id = playerCharacterId("pc-1");
|
const id = playerCharacterId("pc-1");
|
||||||
|
|
||||||
@@ -42,50 +43,32 @@ describe("editPlayerCharacter", () => {
|
|||||||
playerCharacterId("pc-999"),
|
playerCharacterId("pc-999"),
|
||||||
{ name: "Nope" },
|
{ name: "Nope" },
|
||||||
);
|
);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "player-character-not-found");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("player-character-not-found");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects empty name", () => {
|
it("rejects empty name", () => {
|
||||||
const result = editPlayerCharacter([makePC()], id, { name: "" });
|
const result = editPlayerCharacter([makePC()], id, { name: "" });
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-name");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-name");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid AC", () => {
|
it("rejects invalid AC", () => {
|
||||||
const result = editPlayerCharacter([makePC()], id, { ac: -1 });
|
const result = editPlayerCharacter([makePC()], id, { ac: -1 });
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-ac");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-ac");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid maxHp", () => {
|
it("rejects invalid maxHp", () => {
|
||||||
const result = editPlayerCharacter([makePC()], id, { maxHp: 0 });
|
const result = editPlayerCharacter([makePC()], id, { maxHp: 0 });
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-max-hp");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-max-hp");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid color", () => {
|
it("rejects invalid color", () => {
|
||||||
const result = editPlayerCharacter([makePC()], id, { color: "neon" });
|
const result = editPlayerCharacter([makePC()], id, { color: "neon" });
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-color");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-color");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid icon", () => {
|
it("rejects invalid icon", () => {
|
||||||
const result = editPlayerCharacter([makePC()], id, { icon: "banana" });
|
const result = editPlayerCharacter([makePC()], id, { icon: "banana" });
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-icon");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-icon");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error when no fields changed", () => {
|
it("returns error when no fields changed", () => {
|
||||||
@@ -94,10 +77,7 @@ describe("editPlayerCharacter", () => {
|
|||||||
name: pc.name,
|
name: pc.name,
|
||||||
ac: pc.ac,
|
ac: pc.ac,
|
||||||
});
|
});
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "no-changes");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("no-changes");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits exactly one event on success", () => {
|
it("emits exactly one event on success", () => {
|
||||||
@@ -106,6 +86,22 @@ describe("editPlayerCharacter", () => {
|
|||||||
expect(result.events).toHaveLength(1);
|
expect(result.events).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clears color when set to null", () => {
|
||||||
|
const result = editPlayerCharacter([makePC({ color: "green" })], id, {
|
||||||
|
color: null,
|
||||||
|
});
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].color).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears icon when set to null", () => {
|
||||||
|
const result = editPlayerCharacter([makePC({ icon: "sword" })], id, {
|
||||||
|
icon: null,
|
||||||
|
});
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].icon).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("event includes old and new name", () => {
|
it("event includes old and new name", () => {
|
||||||
const result = editPlayerCharacter([makePC()], id, { name: "Strider" });
|
const result = editPlayerCharacter([makePC()], id, { name: "Strider" });
|
||||||
if (isDomainError(result)) throw new Error(result.message);
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { removeCombatant } from "../remove-combatant.js";
|
import { removeCombatant } from "../remove-combatant.js";
|
||||||
import type { Combatant, Encounter } from "../types.js";
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
import { combatantId, isDomainError } from "../types.js";
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
@@ -92,10 +93,7 @@ describe("removeCombatant", () => {
|
|||||||
const e = enc([A, B], 0, 1);
|
const e = enc([A, B], 0, 1);
|
||||||
const result = removeCombatant(e, combatantId("nonexistent"));
|
const result = removeCombatant(e, combatantId("nonexistent"));
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "combatant-not-found");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("combatant-not-found");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type Encounter,
|
type Encounter,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
} from "../types.js";
|
} from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
@@ -83,10 +84,7 @@ describe("retreatTurn", () => {
|
|||||||
const enc = encounter([A, B, C], 0, 1);
|
const enc = encounter([A, B, C], 0, 1);
|
||||||
const result = retreatTurn(enc);
|
const result = retreatTurn(enc);
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "no-previous-turn");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("no-previous-turn");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("scenario 4: single-combatant retreat — wraps to same combatant, decrements round", () => {
|
it("scenario 4: single-combatant retreat — wraps to same combatant, decrements round", () => {
|
||||||
@@ -117,10 +115,7 @@ describe("retreatTurn", () => {
|
|||||||
};
|
};
|
||||||
const result = retreatTurn(enc);
|
const result = retreatTurn(enc);
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-encounter");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-encounter");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { rollInitiative } from "../roll-initiative.js";
|
import { rollInitiative } from "../roll-initiative.js";
|
||||||
import { isDomainError } from "../types.js";
|
import { isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
describe("rollInitiative", () => {
|
describe("rollInitiative", () => {
|
||||||
describe("valid rolls", () => {
|
describe("valid rolls", () => {
|
||||||
@@ -32,18 +33,12 @@ describe("rollInitiative", () => {
|
|||||||
describe("invalid dice rolls", () => {
|
describe("invalid dice rolls", () => {
|
||||||
it("rejects 0", () => {
|
it("rejects 0", () => {
|
||||||
const result = rollInitiative(0, 5);
|
const result = rollInitiative(0, 5);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-dice-roll");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-dice-roll");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects 21", () => {
|
it("rejects 21", () => {
|
||||||
const result = rollInitiative(21, 5);
|
const result = rollInitiative(21, 5);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-dice-roll");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-dice-roll");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects non-integer (3.5)", () => {
|
it("rejects non-integer (3.5)", () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { setAc } from "../set-ac.js";
|
import { setAc } from "../set-ac.js";
|
||||||
import type { Combatant, Encounter } from "../types.js";
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
import { combatantId, isDomainError } from "../types.js";
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
function makeCombatant(name: string, ac?: number): Combatant {
|
function makeCombatant(name: string, ac?: number): Combatant {
|
||||||
return ac === undefined
|
return ac === undefined
|
||||||
@@ -67,30 +68,21 @@ describe("setAc", () => {
|
|||||||
const e = enc([makeCombatant("A")]);
|
const e = enc([makeCombatant("A")]);
|
||||||
const result = setAc(e, combatantId("nonexistent"), 10);
|
const result = setAc(e, combatantId("nonexistent"), 10);
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "combatant-not-found");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("combatant-not-found");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error for negative AC", () => {
|
it("returns error for negative AC", () => {
|
||||||
const e = enc([makeCombatant("A")]);
|
const e = enc([makeCombatant("A")]);
|
||||||
const result = setAc(e, combatantId("A"), -1);
|
const result = setAc(e, combatantId("A"), -1);
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-ac");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-ac");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error for non-integer AC", () => {
|
it("returns error for non-integer AC", () => {
|
||||||
const e = enc([makeCombatant("A")]);
|
const e = enc([makeCombatant("A")]);
|
||||||
const result = setAc(e, combatantId("A"), 3.5);
|
const result = setAc(e, combatantId("A"), 3.5);
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-ac");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-ac");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error for NaN", () => {
|
it("returns error for NaN", () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { setHp } from "../set-hp.js";
|
import { setHp } from "../set-hp.js";
|
||||||
import type { Combatant, Encounter } from "../types.js";
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
import { combatantId, isDomainError } from "../types.js";
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
function makeCombatant(
|
function makeCombatant(
|
||||||
name: string,
|
name: string,
|
||||||
@@ -10,9 +11,9 @@ function makeCombatant(
|
|||||||
return {
|
return {
|
||||||
id: combatantId(name),
|
id: combatantId(name),
|
||||||
name,
|
name,
|
||||||
...(opts?.maxHp !== undefined
|
...(opts?.maxHp === undefined
|
||||||
? { maxHp: opts.maxHp, currentHp: opts.currentHp ?? opts.maxHp }
|
? {}
|
||||||
: {}),
|
: { maxHp: opts.maxHp, currentHp: opts.currentHp ?? opts.maxHp }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,37 +117,25 @@ describe("setHp", () => {
|
|||||||
it("returns error for nonexistent combatant", () => {
|
it("returns error for nonexistent combatant", () => {
|
||||||
const e = enc([makeCombatant("A")]);
|
const e = enc([makeCombatant("A")]);
|
||||||
const result = setHp(e, combatantId("Z"), 10);
|
const result = setHp(e, combatantId("Z"), 10);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "combatant-not-found");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("combatant-not-found");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects maxHp of 0", () => {
|
it("rejects maxHp of 0", () => {
|
||||||
const e = enc([makeCombatant("A")]);
|
const e = enc([makeCombatant("A")]);
|
||||||
const result = setHp(e, combatantId("A"), 0);
|
const result = setHp(e, combatantId("A"), 0);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-max-hp");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-max-hp");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects negative maxHp", () => {
|
it("rejects negative maxHp", () => {
|
||||||
const e = enc([makeCombatant("A")]);
|
const e = enc([makeCombatant("A")]);
|
||||||
const result = setHp(e, combatantId("A"), -5);
|
const result = setHp(e, combatantId("A"), -5);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-max-hp");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-max-hp");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects non-integer maxHp", () => {
|
it("rejects non-integer maxHp", () => {
|
||||||
const e = enc([makeCombatant("A")]);
|
const e = enc([makeCombatant("A")]);
|
||||||
const result = setHp(e, combatantId("A"), 3.5);
|
const result = setHp(e, combatantId("A"), 3.5);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-max-hp");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-max-hp");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { setInitiative } from "../set-initiative.js";
|
import { setInitiative } from "../set-initiative.js";
|
||||||
import type { Combatant, Encounter } from "../types.js";
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
import { combatantId, isDomainError } from "../types.js";
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
@@ -73,10 +74,7 @@ describe("setInitiative", () => {
|
|||||||
const e = enc([A, B], 0);
|
const e = enc([A, B], 0);
|
||||||
const result = setInitiative(e, combatantId("A"), 3.5);
|
const result = setInitiative(e, combatantId("A"), 3.5);
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-initiative");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-initiative");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("AS-3b: reject NaN", () => {
|
it("AS-3b: reject NaN", () => {
|
||||||
@@ -109,10 +107,7 @@ describe("setInitiative", () => {
|
|||||||
const e = enc([A, B], 0);
|
const e = enc([A, B], 0);
|
||||||
const result = setInitiative(e, combatantId("nonexistent"), 10);
|
const result = setInitiative(e, combatantId("nonexistent"), 10);
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "combatant-not-found");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("combatant-not-found");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
9
packages/domain/src/__tests__/test-helpers.ts
Normal file
9
packages/domain/src/__tests__/test-helpers.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { toggleConcentration } from "../toggle-concentration.js";
|
import { toggleConcentration } from "../toggle-concentration.js";
|
||||||
import type { Combatant, Encounter } from "../types.js";
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
import { combatantId, isDomainError } from "../types.js";
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
function makeCombatant(name: string, isConcentrating?: boolean): Combatant {
|
function makeCombatant(name: string, isConcentrating?: boolean): Combatant {
|
||||||
return isConcentrating
|
return isConcentrating
|
||||||
@@ -46,10 +47,7 @@ describe("toggleConcentration", () => {
|
|||||||
const e = enc([makeCombatant("A")]);
|
const e = enc([makeCombatant("A")]);
|
||||||
const result = toggleConcentration(e, combatantId("missing"));
|
const result = toggleConcentration(e, combatantId("missing"));
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "combatant-not-found");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("combatant-not-found");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not mutate input encounter", () => {
|
it("does not mutate input encounter", () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { CONDITION_DEFINITIONS } from "../conditions.js";
|
|||||||
import { toggleCondition } from "../toggle-condition.js";
|
import { toggleCondition } from "../toggle-condition.js";
|
||||||
import type { Combatant, Encounter } from "../types.js";
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
import { combatantId, isDomainError } from "../types.js";
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
function makeCombatant(
|
function makeCombatant(
|
||||||
name: string,
|
name: string,
|
||||||
@@ -77,20 +78,14 @@ describe("toggleCondition", () => {
|
|||||||
"flying" as ConditionId,
|
"flying" as ConditionId,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "unknown-condition");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("unknown-condition");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error for nonexistent combatant", () => {
|
it("returns error for nonexistent combatant", () => {
|
||||||
const e = enc([makeCombatant("A")]);
|
const e = enc([makeCombatant("A")]);
|
||||||
const result = toggleCondition(e, combatantId("missing"), "blinded");
|
const result = toggleCondition(e, combatantId("missing"), "blinded");
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "combatant-not-found");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("combatant-not-found");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not mutate input encounter", () => {
|
it("does not mutate input encounter", () => {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { DomainEvent } from "./events.js";
|
import type { DomainEvent } from "./events.js";
|
||||||
import type { DomainError, Encounter } from "./types.js";
|
import type { DomainError, Encounter } from "./types.js";
|
||||||
import { isDomainError } from "./types.js";
|
|
||||||
|
|
||||||
interface AdvanceTurnSuccess {
|
interface AdvanceTurnSuccess {
|
||||||
readonly encounter: Encounter;
|
readonly encounter: Encounter;
|
||||||
@@ -62,4 +61,4 @@ export function advanceTurn(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { isDomainError };
|
export { isDomainError } from "./types.js";
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ export function resolveCreatureName(
|
|||||||
if (name === baseName) {
|
if (name === baseName) {
|
||||||
exactMatches.push(i);
|
exactMatches.push(i);
|
||||||
} else {
|
} else {
|
||||||
const match = new RegExp(`^${escapeRegExp(baseName)} (\\d+)$`).exec(name);
|
const match = new RegExp(
|
||||||
|
String.raw`^${escapeRegExp(baseName)} (\d+)$`,
|
||||||
|
).exec(name);
|
||||||
|
// biome-ignore lint/nursery/noUnnecessaryConditions: RegExp.exec() returns null on no match — false positive
|
||||||
if (match) {
|
if (match) {
|
||||||
const num = Number.parseInt(match[1], 10);
|
const num = Number.parseInt(match[1], 10);
|
||||||
if (num > maxNumber) maxNumber = num;
|
if (num > maxNumber) maxNumber = num;
|
||||||
@@ -50,5 +53,5 @@ export function resolveCreatureName(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function escapeRegExp(s: string): string {
|
function escapeRegExp(s: string): string {
|
||||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return s.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ export function createPlayerCharacter(
|
|||||||
name: string,
|
name: string,
|
||||||
ac: number,
|
ac: number,
|
||||||
maxHp: number,
|
maxHp: number,
|
||||||
color: string,
|
color: string | undefined,
|
||||||
icon: string,
|
icon: string | undefined,
|
||||||
): CreatePlayerCharacterSuccess | DomainError {
|
): CreatePlayerCharacterSuccess | DomainError {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ export function createPlayerCharacter(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!VALID_PLAYER_COLORS.has(color)) {
|
if (color !== undefined && !VALID_PLAYER_COLORS.has(color)) {
|
||||||
return {
|
return {
|
||||||
kind: "domain-error",
|
kind: "domain-error",
|
||||||
code: "invalid-color",
|
code: "invalid-color",
|
||||||
@@ -57,7 +57,7 @@ export function createPlayerCharacter(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!VALID_PLAYER_ICONS.has(icon)) {
|
if (icon !== undefined && !VALID_PLAYER_ICONS.has(icon)) {
|
||||||
return {
|
return {
|
||||||
kind: "domain-error",
|
kind: "domain-error",
|
||||||
code: "invalid-icon",
|
code: "invalid-icon",
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ interface EditFields {
|
|||||||
readonly name?: string;
|
readonly name?: string;
|
||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly maxHp?: number;
|
readonly maxHp?: number;
|
||||||
readonly color?: string;
|
readonly color?: string | null;
|
||||||
readonly icon?: string;
|
readonly icon?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateFields(fields: EditFields): DomainError | null {
|
function validateFields(fields: EditFields): DomainError | null {
|
||||||
if (fields.name !== undefined && fields.name.trim() === "") {
|
if (fields.name?.trim() === "") {
|
||||||
return {
|
return {
|
||||||
kind: "domain-error",
|
kind: "domain-error",
|
||||||
code: "invalid-name",
|
code: "invalid-name",
|
||||||
@@ -50,14 +50,22 @@ function validateFields(fields: EditFields): DomainError | null {
|
|||||||
message: "Max HP must be a positive integer",
|
message: "Max HP must be a positive integer",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (fields.color !== undefined && !VALID_PLAYER_COLORS.has(fields.color)) {
|
if (
|
||||||
|
fields.color !== undefined &&
|
||||||
|
fields.color !== null &&
|
||||||
|
!VALID_PLAYER_COLORS.has(fields.color)
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
kind: "domain-error",
|
kind: "domain-error",
|
||||||
code: "invalid-color",
|
code: "invalid-color",
|
||||||
message: `Invalid color: ${fields.color}`,
|
message: `Invalid color: ${fields.color}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (fields.icon !== undefined && !VALID_PLAYER_ICONS.has(fields.icon)) {
|
if (
|
||||||
|
fields.icon !== undefined &&
|
||||||
|
fields.icon !== null &&
|
||||||
|
!VALID_PLAYER_ICONS.has(fields.icon)
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
kind: "domain-error",
|
kind: "domain-error",
|
||||||
code: "invalid-icon",
|
code: "invalid-icon",
|
||||||
@@ -73,17 +81,17 @@ function applyFields(
|
|||||||
): PlayerCharacter {
|
): PlayerCharacter {
|
||||||
return {
|
return {
|
||||||
id: existing.id,
|
id: existing.id,
|
||||||
name: fields.name !== undefined ? fields.name.trim() : existing.name,
|
name: fields.name?.trim() ?? existing.name,
|
||||||
ac: fields.ac !== undefined ? fields.ac : existing.ac,
|
ac: fields.ac ?? existing.ac,
|
||||||
maxHp: fields.maxHp !== undefined ? fields.maxHp : existing.maxHp,
|
maxHp: fields.maxHp ?? existing.maxHp,
|
||||||
color:
|
color:
|
||||||
fields.color !== undefined
|
fields.color === undefined
|
||||||
? (fields.color as PlayerCharacter["color"])
|
? existing.color
|
||||||
: existing.color,
|
: ((fields.color as PlayerCharacter["color"]) ?? undefined),
|
||||||
icon:
|
icon:
|
||||||
fields.icon !== undefined
|
fields.icon === undefined
|
||||||
? (fields.icon as PlayerCharacter["icon"])
|
? existing.icon
|
||||||
: existing.icon,
|
: ((fields.icon as PlayerCharacter["icon"]) ?? undefined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ export interface PlayerCharacter {
|
|||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly ac: number;
|
readonly ac: number;
|
||||||
readonly maxHp: number;
|
readonly maxHp: number;
|
||||||
readonly color: PlayerColor;
|
readonly color?: PlayerColor;
|
||||||
readonly icon: PlayerIcon;
|
readonly icon?: PlayerIcon;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayerCharacterList {
|
export interface PlayerCharacterList {
|
||||||
|
|||||||
@@ -21,15 +21,13 @@ export function setAc(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value !== undefined) {
|
if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
|
||||||
if (!Number.isInteger(value) || value < 0) {
|
|
||||||
return {
|
return {
|
||||||
kind: "domain-error",
|
kind: "domain-error",
|
||||||
code: "invalid-ac",
|
code: "invalid-ac",
|
||||||
message: `AC must be a non-negative integer, got ${value}`,
|
message: `AC must be a non-negative integer, got ${value}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const target = encounter.combatants[targetIdx];
|
const target = encounter.combatants[targetIdx];
|
||||||
const previousAc = target.ac;
|
const previousAc = target.ac;
|
||||||
|
|||||||
@@ -28,15 +28,13 @@ export function setHp(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maxHp !== undefined) {
|
if (maxHp !== undefined && (!Number.isInteger(maxHp) || maxHp < 1)) {
|
||||||
if (!Number.isInteger(maxHp) || maxHp < 1) {
|
|
||||||
return {
|
return {
|
||||||
kind: "domain-error",
|
kind: "domain-error",
|
||||||
code: "invalid-max-hp",
|
code: "invalid-max-hp",
|
||||||
message: `Max HP must be a positive integer, got ${maxHp}`,
|
message: `Max HP must be a positive integer, got ${maxHp}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const target = encounter.combatants[targetIdx];
|
const target = encounter.combatants[targetIdx];
|
||||||
const previousMaxHp = target.maxHp;
|
const previousMaxHp = target.maxHp;
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function setInitiative(
|
|||||||
const aInit = a.c.initiative as number;
|
const aInit = a.c.initiative as number;
|
||||||
const bInit = b.c.initiative as number;
|
const bInit = b.c.initiative as number;
|
||||||
const diff = bInit - aInit;
|
const diff = bInit - aInit;
|
||||||
return diff !== 0 ? diff : a.i - b.i;
|
return diff === 0 ? a.i - b.i : diff;
|
||||||
}
|
}
|
||||||
if (aHas && !bHas) return -1;
|
if (aHas && !bHas) return -1;
|
||||||
if (!aHas && bHas) return 1;
|
if (!aHas && bHas) return 1;
|
||||||
|
|||||||
369
pnpm-lock.yaml
generated
369
pnpm-lock.yaml
generated
@@ -4,13 +4,16 @@ settings:
|
|||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
undici: '>=7.24.0'
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@biomejs/biome':
|
'@biomejs/biome':
|
||||||
specifier: 2.0.0
|
specifier: 2.4.7
|
||||||
version: 2.0.0
|
version: 2.4.7
|
||||||
'@vitest/coverage-v8':
|
'@vitest/coverage-v8':
|
||||||
specifier: ^3.2.4
|
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))
|
version: 3.2.4(vitest@3.2.4(@types/node@25.3.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.31.1))
|
||||||
@@ -23,6 +26,12 @@ importers:
|
|||||||
lefthook:
|
lefthook:
|
||||||
specifier: ^1.11.0
|
specifier: ^1.11.0
|
||||||
version: 1.13.6
|
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:
|
typescript:
|
||||||
specifier: ^5.8.0
|
specifier: ^5.8.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -69,6 +78,9 @@ importers:
|
|||||||
'@testing-library/react':
|
'@testing-library/react':
|
||||||
specifier: ^16.3.2
|
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)
|
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':
|
'@types/react':
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.14
|
version: 19.2.14
|
||||||
@@ -209,55 +221,55 @@ packages:
|
|||||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
'@biomejs/biome@2.0.0':
|
'@biomejs/biome@2.4.7':
|
||||||
resolution: {integrity: sha512-BlUoXEOI/UQTDEj/pVfnkMo8SrZw3oOWBDrXYFT43V7HTkIUDkBRY53IC5Jx1QkZbaB+0ai1wJIfYwp9+qaJTQ==}
|
resolution: {integrity: sha512-vXrgcmNGZ4lpdwZSpMf1hWw1aWS6B+SyeSYKTLrNsiUsAdSRN0J4d/7mF3ogJFbIwFFSOL3wT92Zzxia/d5/ng==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@biomejs/cli-darwin-arm64@2.0.0':
|
'@biomejs/cli-darwin-arm64@2.4.7':
|
||||||
resolution: {integrity: sha512-QvqWYtFFhhxdf8jMAdJzXW+Frc7X8XsnHQLY+TBM1fnT1TfeV/v9vsFI5L2J7GH6qN1+QEEJ19jHibCY2Ypplw==}
|
resolution: {integrity: sha512-Oo0cF5mHzmvDmTXw8XSjhCia8K6YrZnk7aCS54+/HxyMdZMruMO3nfpDsrlar/EQWe41r1qrwKiCa2QDYHDzWA==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@biomejs/cli-darwin-x64@2.0.0':
|
'@biomejs/cli-darwin-x64@2.4.7':
|
||||||
resolution: {integrity: sha512-5JFhls1EfmuIH4QGFPlNpxJQFC6ic3X1ltcoLN+eSRRIPr6H/lUS1ttuD0Fj7rPgPhZqopK/jfH8UVj/1hIsQw==}
|
resolution: {integrity: sha512-I+cOG3sd/7HdFtvDSnF9QQPrWguUH7zrkIMMykM3PtfWU9soTcS2yRb9Myq6MHmzbeCT08D1UmY+BaiMl5CcoQ==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64-musl@2.0.0':
|
'@biomejs/cli-linux-arm64-musl@2.4.7':
|
||||||
resolution: {integrity: sha512-Bxsz8ki8+b3PytMnS5SgrGV+mbAWwIxI3ydChb/d1rURlJTMdxTTq5LTebUnlsUWAX6OvJuFeiVq9Gjn1YbCyA==}
|
resolution: {integrity: sha512-I2NvM9KPb09jWml93O2/5WMfNR7Lee5Latag1JThDRMURVhPX74p9UDnyTw3Ae6cE1DgXfw7sqQgX7rkvpc0vw==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64@2.0.0':
|
'@biomejs/cli-linux-arm64@2.4.7':
|
||||||
resolution: {integrity: sha512-BAH4QVi06TzAbVchXdJPsL0Z/P87jOfes15rI+p3EX9/EGTfIjaQ9lBVlHunxcmoptaA5y1Hdb9UYojIhmnjIw==}
|
resolution: {integrity: sha512-om6FugwmibzfP/6ALj5WRDVSND4H2G9X0nkI1HZpp2ySf9lW2j0X68oQSaHEnls6666oy4KDsc5RFjT4m0kV0w==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64-musl@2.0.0':
|
'@biomejs/cli-linux-x64-musl@2.4.7':
|
||||||
resolution: {integrity: sha512-tiQ0ABxMJb9I6GlfNp0ulrTiQSFacJRJO8245FFwE3ty3bfsfxlU/miblzDIi+qNrgGsLq5wIZcVYGp4c+HXZA==}
|
resolution: {integrity: sha512-00kx4YrBMU8374zd2wHuRV5wseh0rom5HqRND+vDldJPrWwQw+mzd/d8byI9hPx926CG+vWzq6AeiT7Yi5y59g==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64@2.0.0':
|
'@biomejs/cli-linux-x64@2.4.7':
|
||||||
resolution: {integrity: sha512-09PcOGYTtkopWRm6mZ/B6Mr6UHdkniUgIG/jLBv+2J8Z61ezRE+xQmpi3yNgUrFIAU4lPA9atg7mhvE/5Bo7Wg==}
|
resolution: {integrity: sha512-bV8/uo2Tj+gumnk4sUdkerWyCPRabaZdv88IpbmDWARQQoA/Q0YaqPz1a+LSEDIL7OfrnPi9Hq1Llz4ZIGyIQQ==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@biomejs/cli-win32-arm64@2.0.0':
|
'@biomejs/cli-win32-arm64@2.4.7':
|
||||||
resolution: {integrity: sha512-vrTtuGu91xNTEQ5ZcMJBZuDlqr32DWU1r14UfePIGndF//s2WUAmer4FmgoPgruo76rprk37e8S2A2c0psXdxw==}
|
resolution: {integrity: sha512-hOUHBMlFCvDhu3WCq6vaBoG0dp0LkWxSEnEEsxxXvOa9TfT6ZBnbh72A/xBM7CBYB7WgwqboetzFEVDnMxelyw==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@biomejs/cli-win32-x64@2.0.0':
|
'@biomejs/cli-win32-x64@2.4.7':
|
||||||
resolution: {integrity: sha512-2USVQ0hklNsph/KIR72ZdeptyXNnQ3JdzPn3NbjI4Sna34CnxeiYAaZcZzXPDl5PYNFBivV4xmvT3Z3rTmyDBg==}
|
resolution: {integrity: sha512-qEpGjSkPC3qX4ycbMUthXvi9CkRq7kZpkqMY1OyhmYlYLnANnooDQ7hDerM8+0NJ+DZKVnsIc07h30XOpt7LtQ==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
@@ -629,6 +641,150 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
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':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -874,6 +1030,12 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
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':
|
'@tybys/wasm-util@0.10.1':
|
||||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||||
|
|
||||||
@@ -1709,6 +1871,20 @@ packages:
|
|||||||
oxc-resolver@11.19.1:
|
oxc-resolver@11.19.1:
|
||||||
resolution: {integrity: sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==}
|
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:
|
package-json-from-dist@1.0.1:
|
||||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||||
|
|
||||||
@@ -2011,8 +2187,8 @@ packages:
|
|||||||
undici-types@7.18.2:
|
undici-types@7.18.2:
|
||||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||||
|
|
||||||
undici@7.22.0:
|
undici@7.24.2:
|
||||||
resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==}
|
resolution: {integrity: sha512-P9J1HWYV/ajFr8uCqk5QixwiRKmB1wOamgS0e+o2Z4A44Ej2+thFVRLG/eA7qprx88XXhnV5Bl8LHXTURpzB3Q==}
|
||||||
engines: {node: '>=20.18.1'}
|
engines: {node: '>=20.18.1'}
|
||||||
|
|
||||||
universalify@2.0.1:
|
universalify@2.0.1:
|
||||||
@@ -2305,39 +2481,39 @@ snapshots:
|
|||||||
|
|
||||||
'@bcoe/v8-coverage@1.0.2': {}
|
'@bcoe/v8-coverage@1.0.2': {}
|
||||||
|
|
||||||
'@biomejs/biome@2.0.0':
|
'@biomejs/biome@2.4.7':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@biomejs/cli-darwin-arm64': 2.0.0
|
'@biomejs/cli-darwin-arm64': 2.4.7
|
||||||
'@biomejs/cli-darwin-x64': 2.0.0
|
'@biomejs/cli-darwin-x64': 2.4.7
|
||||||
'@biomejs/cli-linux-arm64': 2.0.0
|
'@biomejs/cli-linux-arm64': 2.4.7
|
||||||
'@biomejs/cli-linux-arm64-musl': 2.0.0
|
'@biomejs/cli-linux-arm64-musl': 2.4.7
|
||||||
'@biomejs/cli-linux-x64': 2.0.0
|
'@biomejs/cli-linux-x64': 2.4.7
|
||||||
'@biomejs/cli-linux-x64-musl': 2.0.0
|
'@biomejs/cli-linux-x64-musl': 2.4.7
|
||||||
'@biomejs/cli-win32-arm64': 2.0.0
|
'@biomejs/cli-win32-arm64': 2.4.7
|
||||||
'@biomejs/cli-win32-x64': 2.0.0
|
'@biomejs/cli-win32-x64': 2.4.7
|
||||||
|
|
||||||
'@biomejs/cli-darwin-arm64@2.0.0':
|
'@biomejs/cli-darwin-arm64@2.4.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-darwin-x64@2.0.0':
|
'@biomejs/cli-darwin-x64@2.4.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64-musl@2.0.0':
|
'@biomejs/cli-linux-arm64-musl@2.4.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64@2.0.0':
|
'@biomejs/cli-linux-arm64@2.4.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64-musl@2.0.0':
|
'@biomejs/cli-linux-x64-musl@2.4.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64@2.0.0':
|
'@biomejs/cli-linux-x64@2.4.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-win32-arm64@2.0.0':
|
'@biomejs/cli-win32-arm64@2.4.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-win32-x64@2.0.0':
|
'@biomejs/cli-win32-x64@2.4.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@bramus/specificity@2.4.2':
|
'@bramus/specificity@2.4.2':
|
||||||
@@ -2611,6 +2787,81 @@ snapshots:
|
|||||||
'@oxc-resolver/binding-win32-x64-msvc@11.19.1':
|
'@oxc-resolver/binding-win32-x64-msvc@11.19.1':
|
||||||
optional: true
|
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':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -2789,6 +3040,10 @@ snapshots:
|
|||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
'@types/react-dom': 19.2.3(@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':
|
'@tybys/wasm-util@0.10.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -3420,7 +3675,7 @@ snapshots:
|
|||||||
saxes: 6.0.0
|
saxes: 6.0.0
|
||||||
symbol-tree: 3.2.4
|
symbol-tree: 3.2.4
|
||||||
tough-cookie: 6.0.0
|
tough-cookie: 6.0.0
|
||||||
undici: 7.22.0
|
undici: 7.24.2
|
||||||
w3c-xmlserializer: 5.0.0
|
w3c-xmlserializer: 5.0.0
|
||||||
webidl-conversions: 8.0.1
|
webidl-conversions: 8.0.1
|
||||||
whatwg-mimetype: 5.0.0
|
whatwg-mimetype: 5.0.0
|
||||||
@@ -3665,6 +3920,38 @@ snapshots:
|
|||||||
'@oxc-resolver/binding-win32-ia32-msvc': 11.19.1
|
'@oxc-resolver/binding-win32-ia32-msvc': 11.19.1
|
||||||
'@oxc-resolver/binding-win32-x64-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: {}
|
package-json-from-dist@1.0.1: {}
|
||||||
|
|
||||||
parse5@8.0.0:
|
parse5@8.0.0:
|
||||||
@@ -3973,7 +4260,7 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@7.18.2: {}
|
undici-types@7.18.2: {}
|
||||||
|
|
||||||
undici@7.22.0: {}
|
undici@7.24.2: {}
|
||||||
|
|
||||||
universalify@2.0.1: {}
|
universalify@2.0.1: {}
|
||||||
|
|
||||||
|
|||||||
108
scripts/check-lint-ignores.mjs
Normal file
108
scripts/check-lint-ignores.mjs
Normal 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.");
|
||||||
|
}
|
||||||
@@ -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)**
|
**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**:
|
**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.
|
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.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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.
|
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
|
#### 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
|
#### 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.
|
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: 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: 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.
|
- **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.
|
- **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).
|
||||||
- **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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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.
|
- 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 `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 `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.
|
||||||
|
|||||||
@@ -419,16 +419,15 @@ Acceptance scenarios:
|
|||||||
3. **Given** any combatant row, **When** hovered and concentration is inactive, **Then** the Brain icon becomes visible.
|
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.
|
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)**
|
**Story ROW-3 — Book Icon 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.
|
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:
|
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.
|
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** 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.
|
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 combatant does NOT have a linked creature, **When** the row is clicked, **Then** nothing happens.
|
3. **Given** a bestiary combatant row, **When** the user clicks the BookOpen icon, **Then** the stat block panel opens for that creature.
|
||||||
4. **Given** viewing any bestiary combatant row, **Then** no BookOpen icon is visible.
|
4. **Given** the user clicks the combatant's name, **Then** inline rename mode is entered — the stat block does NOT open.
|
||||||
5. **Given** a bestiary combatant row, **When** the user hovers over non-interactive areas, **Then** the cursor indicates clickability.
|
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).
|
||||||
6. **Given** the stat block is already open for a creature, **When** the same row is clicked, **Then** the panel closes (toggle behavior).
|
|
||||||
|
|
||||||
### Requirements
|
### 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-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-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-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-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 interactive elements (initiative, HP, AC, conditions, "+", "x", concentration) MUST NOT trigger the stat block — only the element's own action.
|
- **FR-085**: Clicking the combatant name MUST enter inline rename mode, not open the stat block.
|
||||||
- **FR-086**: The BookOpen icon MUST be removed from the combatant row.
|
- **FR-086**: Non-bestiary combatant rows MUST NOT display the BookOpen icon.
|
||||||
- **FR-087**: Bestiary combatant rows MUST show a pointer cursor on hover over non-interactive areas.
|
- **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-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-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.
|
- **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.
|
- 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.
|
- 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.
|
- When the stat block panel is already open and the user clicks the same BookOpen icon again, the panel closes.
|
||||||
- Clicking the initiative area starts editing; it does not open the stat block.
|
- 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.
|
- Tablet-width screens (>= 768 px / Tailwind `md`): popovers and inline edits MUST remain accessible and not overflow or clip.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## Overview
|
## 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.
|
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)**
|
**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.
|
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)**
|
**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.
|
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-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-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-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-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
|
### 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).
|
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.
|
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.
|
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.
|
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.
|
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).
|
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
|
### Edge Cases
|
||||||
|
|
||||||
@@ -210,58 +214,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
|
### 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.
|
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)**
|
**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.
|
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.
|
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)**
|
**US-P3 — Collapse 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.
|
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
|
### 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-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-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**: Folding and unfolding MUST use a smooth CSS slide animation (~200ms ease-out).
|
- **FR-053**: Collapsing and expanding 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-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-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-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-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-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-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-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 fold states.
|
- **FR-061**: Both the browse (right) and pinned (left) panels MUST have independent collapsed states.
|
||||||
|
|
||||||
### Acceptance Scenarios
|
### 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.
|
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 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.
|
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 fold toggle.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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 folded and the left panel is pinned, **When** the user unfolds the right panel, **Then** it slides back showing the last browsed creature.
|
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
|
### 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 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 or collapsed: advancing turns does not auto-show or update the stat block panel. The panel only changes when the user explicitly clicks a book icon. If the panel is collapsed, it stays collapsed.
|
||||||
- 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.
|
- 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.
|
- Panel showing a source fetch prompt: the pin button is hidden.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -276,7 +280,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.
|
- **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).
|
- **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.
|
- **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 +299,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-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-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-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-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-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.
|
||||||
|
|||||||
@@ -7,19 +7,36 @@ export default defineConfig({
|
|||||||
coverage: {
|
coverage: {
|
||||||
provider: "v8",
|
provider: "v8",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
exclude: ["**/dist/**"],
|
||||||
thresholds: {
|
thresholds: {
|
||||||
autoUpdate: true,
|
autoUpdate: true,
|
||||||
"packages/domain/src": {
|
"packages/domain/src": {
|
||||||
lines: 96,
|
lines: 99,
|
||||||
branches: 96,
|
branches: 97,
|
||||||
|
},
|
||||||
|
"packages/application/src": {
|
||||||
|
lines: 97,
|
||||||
|
branches: 94,
|
||||||
},
|
},
|
||||||
"apps/web/src/adapters": {
|
"apps/web/src/adapters": {
|
||||||
lines: 71,
|
lines: 72,
|
||||||
branches: 78,
|
branches: 78,
|
||||||
},
|
},
|
||||||
"apps/web/src/persistence": {
|
"apps/web/src/persistence": {
|
||||||
lines: 87,
|
lines: 90,
|
||||||
branches: 67,
|
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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user