Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d0ec0c7b2 | ||
|
|
fe62f2eb2f | ||
|
|
7092677273 | ||
|
|
e1a06c9d59 | ||
|
|
4043612ccf | ||
|
|
cfd4aef724 | ||
|
|
968cc7239b | ||
|
|
d9562f850c | ||
|
|
ec9f2e7877 | ||
|
|
c4079c384b | ||
|
|
a4285fc415 | ||
|
|
9c0e3398f1 | ||
|
|
9cdf004c15 | ||
|
|
8bf69fd47d | ||
|
|
7b83e3c3ea | ||
|
|
c3c2cad798 | ||
|
|
3f6140303d | ||
|
|
fd30278474 | ||
|
|
278c06221f | ||
|
|
722e8cc627 | ||
|
|
64741956dd | ||
|
|
6336dec38a | ||
|
|
9def2d7c24 | ||
|
|
f729e37689 | ||
|
|
86768842ff | ||
|
|
6584d8d064 | ||
|
|
7f38cbab73 | ||
|
|
2971898f0c | ||
|
|
43780772f6 | ||
|
|
7b3dbe2069 | ||
|
|
827a3978e9 | ||
|
|
f024562a7d | ||
|
|
dfef2194a5 | ||
|
|
502adca81b | ||
|
|
12e8bf6e69 | ||
|
|
472574ac31 | ||
|
|
f4a7b53393 | ||
|
|
8aec460ee4 | ||
|
|
6e10238fe0 | ||
|
|
b6e882add2 | ||
|
|
7a87d979bf | ||
|
|
02096bcee6 | ||
|
|
c092192b0e | ||
|
|
4d1a7c6420 | ||
|
|
46b444caba | ||
|
|
e68145319f | ||
|
|
d64e1f5e4a |
@@ -1,9 +1,9 @@
|
||||
<!--
|
||||
Sync Impact Report
|
||||
───────────────────
|
||||
Version change: 2.2.1 → 3.0.0 (MAJOR — specs describe features not changes, proportional workflow)
|
||||
Version change: 3.0.0 → 3.1.0 (MINOR — new principle II-A: context-based state flow)
|
||||
Modified sections:
|
||||
- Development Workflow: specs are living feature documents; full pipeline for new features only
|
||||
- Core Principles: added II-A. Context-Based State Flow (max 8 props, context over prop drilling)
|
||||
Templates requiring updates: none
|
||||
-->
|
||||
# Encounter Console Constitution
|
||||
@@ -38,6 +38,22 @@ dependency direction:
|
||||
|
||||
A module in an inner layer MUST NOT import from an outer layer.
|
||||
|
||||
### II-A. Context-Based State Flow
|
||||
|
||||
UI components MUST consume shared application state via React context
|
||||
providers, not prop drilling. Props are reserved for per-instance
|
||||
configuration (e.g., a specific data item, a layout variant, a ref).
|
||||
|
||||
- Components MUST NOT declare more than 8 explicit props in their
|
||||
own interface. This is enforced by `scripts/check-component-props.mjs`
|
||||
at pre-commit.
|
||||
- Generic UI primitives (`components/ui/`) that extend HTML element
|
||||
attributes are exempt — only explicitly declared props count, not
|
||||
inherited HTML attributes.
|
||||
- Coordinating hooks that consume multiple contexts (e.g.,
|
||||
`useInitiativeRolls`) are preferred over wiring callbacks through
|
||||
a parent component.
|
||||
|
||||
### III. Clarification-First
|
||||
|
||||
Before making any non-trivial assumption during specification,
|
||||
@@ -140,4 +156,4 @@ MUST comply with its principles.
|
||||
**Compliance review**: Every spec and plan MUST include a
|
||||
Constitution Check section validating adherence to all principles.
|
||||
|
||||
**Version**: 3.0.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-11
|
||||
**Version**: 3.1.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-19
|
||||
|
||||
@@ -13,6 +13,7 @@ pnpm test:watch # Tests in watch mode
|
||||
pnpm typecheck # tsc --build (project references)
|
||||
pnpm lint # Biome lint
|
||||
pnpm format # Biome format (writes)
|
||||
pnpm check:props # Component prop count enforcement (max 8)
|
||||
pnpm --filter web dev # Vite dev server (localhost:5173)
|
||||
pnpm --filter web build # Production build
|
||||
```
|
||||
@@ -71,6 +72,7 @@ docs/agents/ RPI skill artifacts (research reports, plans)
|
||||
- **Domain events** are plain data objects with a `type` discriminant — no classes.
|
||||
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks.
|
||||
- **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`.
|
||||
- **Component props** — max 8 explicitly declared props per component interface (enforced by `scripts/check-component-props.mjs`). Use React context for shared state; reserve props for per-instance config (data items, layout variants, refs).
|
||||
- **Quality gates** are enforced at pre-commit via Lefthook's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process.
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0e1a2e" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta property="og:title" content="Initiative Tracker" />
|
||||
<meta property="og:description" content="D&D combat initiative tracker" />
|
||||
<meta property="og:image" content="https://initiative.dostulata.rocks/icon-512.png" />
|
||||
<meta property="og:url" content="https://initiative.dostulata.rocks/" />
|
||||
<meta property="og:type" content="website" />
|
||||
<title>Initiative Tracker</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -20,15 +20,15 @@
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"vite": "^6.2.0"
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"jsdom": "^29.0.1",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"vite": "^8.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
apps/web/public/apple-touch-icon.png
Normal file
BIN
apps/web/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
21
apps/web/public/favicon.svg
Normal file
21
apps/web/public/favicon.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<defs>
|
||||
<linearGradient id="f" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#60a5fa"/>
|
||||
<stop offset="100%" stop-color="#2563eb"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g transform="translate(16 15) scale(1.55)" fill="none" stroke="url(#f)" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76" fill="url(#f)" fill-opacity="0.15"/>
|
||||
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49" fill="url(#f)" fill-opacity="0.25"/>
|
||||
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44" fill="url(#f)" fill-opacity="0.2"/>
|
||||
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44" fill="url(#f)" fill-opacity="0.1"/>
|
||||
<line x1="-4.75" y1="-2.74" x2="-8.19" y2="-4.74"/>
|
||||
<line x1="8.18" y1="-4.74" x2="4.75" y2="-2.74"/>
|
||||
<line x1="-0.01" y1="5.44" x2="-0.01" y2="9.51"/>
|
||||
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76"/>
|
||||
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49"/>
|
||||
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44"/>
|
||||
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
apps/web/public/icon-192.png
Normal file
BIN
apps/web/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
apps/web/public/icon-512.png
Normal file
BIN
apps/web/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
31
apps/web/public/icon.svg
Normal file
31
apps/web/public/icon.svg
Normal file
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<radialGradient id="bg" cx="50%" cy="40%" r="70%">
|
||||
<stop offset="0%" stop-color="#1a2e4a"/>
|
||||
<stop offset="100%" stop-color="#0e1a2e"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="d20fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#60a5fa"/>
|
||||
<stop offset="100%" stop-color="#2563eb"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="d20stroke" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#93c5fd"/>
|
||||
<stop offset="100%" stop-color="#3b82f6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
|
||||
<g transform="translate(256 256) scale(8.5)" fill="none" stroke="url(#d20stroke)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76" fill="url(#d20fill)" fill-opacity="0.15"/>
|
||||
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49" fill="url(#d20fill)" fill-opacity="0.25"/>
|
||||
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44" fill="url(#d20fill)" fill-opacity="0.2"/>
|
||||
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44" fill="url(#d20fill)" fill-opacity="0.1"/>
|
||||
<line x1="-4.75" y1="-2.74" x2="-8.19" y2="-4.74"/>
|
||||
<line x1="8.18" y1="-4.74" x2="4.75" y2="-2.74"/>
|
||||
<line x1="-0.01" y1="5.44" x2="-0.01" y2="9.51"/>
|
||||
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76"/>
|
||||
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49"/>
|
||||
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44"/>
|
||||
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44"/>
|
||||
</g>
|
||||
<text x="256" y="278" text-anchor="middle" dominant-baseline="central" font-family="Inter, system-ui, sans-serif" font-weight="700" font-size="52" fill="#93c5fd" letter-spacing="1">20</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
21
apps/web/public/manifest.json
Normal file
21
apps/web/public/manifest.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Initiative Tracker",
|
||||
"short_name": "Initiative",
|
||||
"description": "D&D combat initiative tracker",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0e1a2e",
|
||||
"theme_color": "#0e1a2e",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,267 +1,76 @@
|
||||
import {
|
||||
rollAllInitiativeUseCase,
|
||||
rollInitiativeUseCase,
|
||||
} from "@initiative/application";
|
||||
import {
|
||||
type CombatantId,
|
||||
type Creature,
|
||||
type CreatureId,
|
||||
isDomainError,
|
||||
} from "@initiative/domain";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ActionBar } from "./components/action-bar";
|
||||
import { BulkImportToasts } from "./components/bulk-import-toasts";
|
||||
import { CombatantRow } from "./components/combatant-row";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ActionBar } from "./components/action-bar.js";
|
||||
import { BulkImportToasts } from "./components/bulk-import-toasts.js";
|
||||
import { CombatantRow } from "./components/combatant-row.js";
|
||||
import {
|
||||
PlayerCharacterSection,
|
||||
type PlayerCharacterSectionHandle,
|
||||
} from "./components/player-character-section";
|
||||
import { StatBlockPanel } from "./components/stat-block-panel";
|
||||
import { Toast } from "./components/toast";
|
||||
import { TurnNavigation } from "./components/turn-navigation";
|
||||
import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
|
||||
import { useBulkImport } from "./hooks/use-bulk-import";
|
||||
import { useEncounter } from "./hooks/use-encounter";
|
||||
import { usePlayerCharacters } from "./hooks/use-player-characters";
|
||||
import { useSidePanelState } from "./hooks/use-side-panel-state";
|
||||
|
||||
function rollDice(): number {
|
||||
return Math.floor(Math.random() * 20) + 1;
|
||||
}
|
||||
|
||||
function useActionBarAnimation(combatantCount: number) {
|
||||
const wasEmptyRef = useRef(combatantCount === 0);
|
||||
const [settling, setSettling] = useState(false);
|
||||
const [rising, setRising] = useState(false);
|
||||
const [topBarExiting, setTopBarExiting] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const nowEmpty = combatantCount === 0;
|
||||
if (wasEmptyRef.current && !nowEmpty) {
|
||||
setSettling(true);
|
||||
} else if (!wasEmptyRef.current && nowEmpty) {
|
||||
setRising(true);
|
||||
setTopBarExiting(true);
|
||||
}
|
||||
wasEmptyRef.current = nowEmpty;
|
||||
}, [combatantCount]);
|
||||
|
||||
const empty = combatantCount === 0;
|
||||
const risingClass = rising ? " animate-rise-to-center" : "";
|
||||
const settlingClass = settling ? " animate-settle-to-bottom" : "";
|
||||
const exitingClass = topBarExiting
|
||||
? " absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
|
||||
: "";
|
||||
const topBarClass = settling ? " animate-slide-down-in" : exitingClass;
|
||||
const showTopBar = !empty || topBarExiting;
|
||||
|
||||
return {
|
||||
risingClass,
|
||||
settlingClass,
|
||||
topBarClass,
|
||||
showTopBar,
|
||||
onSettleEnd: () => setSettling(false),
|
||||
onRiseEnd: () => setRising(false),
|
||||
onTopBarExitEnd: () => setTopBarExiting(false),
|
||||
};
|
||||
}
|
||||
} from "./components/player-character-section.js";
|
||||
import { SettingsModal } from "./components/settings-modal.js";
|
||||
import { StatBlockPanel } from "./components/stat-block-panel.js";
|
||||
import { Toast } from "./components/toast.js";
|
||||
import { TurnNavigation } from "./components/turn-navigation.js";
|
||||
import { useEncounterContext } from "./contexts/encounter-context.js";
|
||||
import { useInitiativeRollsContext } from "./contexts/initiative-rolls-context.js";
|
||||
import { useSidePanelContext } from "./contexts/side-panel-context.js";
|
||||
import { useActionBarAnimation } from "./hooks/use-action-bar-animation.js";
|
||||
import { useAutoStatBlock } from "./hooks/use-auto-stat-block.js";
|
||||
import { cn } from "./lib/utils.js";
|
||||
|
||||
export function App() {
|
||||
const {
|
||||
encounter,
|
||||
isEmpty,
|
||||
hasCreatureCombatants,
|
||||
canRollAllInitiative,
|
||||
advanceTurn,
|
||||
retreatTurn,
|
||||
addCombatant,
|
||||
clearEncounter,
|
||||
removeCombatant,
|
||||
editCombatant,
|
||||
setInitiative,
|
||||
setHp,
|
||||
adjustHp,
|
||||
setAc,
|
||||
toggleCondition,
|
||||
toggleConcentration,
|
||||
addFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
makeStore,
|
||||
} = useEncounter();
|
||||
const { encounter, isEmpty } = useEncounterContext();
|
||||
const sidePanel = useSidePanelContext();
|
||||
const rolls = useInitiativeRollsContext();
|
||||
|
||||
const {
|
||||
characters: playerCharacters,
|
||||
createCharacter: createPlayerCharacter,
|
||||
editCharacter: editPlayerCharacter,
|
||||
deleteCharacter: deletePlayerCharacter,
|
||||
} = usePlayerCharacters();
|
||||
useAutoStatBlock();
|
||||
|
||||
const {
|
||||
search,
|
||||
getCreature,
|
||||
isLoaded,
|
||||
isSourceCached,
|
||||
fetchAndCacheSource,
|
||||
uploadAndCacheSource,
|
||||
refreshCache,
|
||||
} = useBestiary();
|
||||
|
||||
const bulkImport = useBulkImport();
|
||||
const sidePanel = useSidePanelState();
|
||||
|
||||
const [rollSkippedCount, setRollSkippedCount] = useState(0);
|
||||
|
||||
const selectedCreature: Creature | null = sidePanel.selectedCreatureId
|
||||
? (getCreature(sidePanel.selectedCreatureId) ?? null)
|
||||
: null;
|
||||
|
||||
const pinnedCreature: Creature | null = sidePanel.pinnedCreatureId
|
||||
? (getCreature(sidePanel.pinnedCreatureId) ?? null)
|
||||
: null;
|
||||
|
||||
const handleAddFromBestiary = useCallback(
|
||||
(result: SearchResult) => {
|
||||
addFromBestiary(result);
|
||||
},
|
||||
[addFromBestiary],
|
||||
);
|
||||
|
||||
const handleCombatantStatBlock = useCallback(
|
||||
(creatureId: string) => {
|
||||
sidePanel.showCreature(creatureId as CreatureId);
|
||||
},
|
||||
[sidePanel.showCreature],
|
||||
);
|
||||
|
||||
const handleRollInitiative = useCallback(
|
||||
(id: CombatantId) => {
|
||||
rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature);
|
||||
},
|
||||
[makeStore, getCreature],
|
||||
);
|
||||
|
||||
const handleRollAllInitiative = useCallback(() => {
|
||||
const result = rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
||||
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
||||
setRollSkippedCount(result.skippedNoSource);
|
||||
}
|
||||
}, [makeStore, getCreature]);
|
||||
|
||||
const handleViewStatBlock = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const slug = result.name
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
.replaceAll(/(^-|-$)/g, "");
|
||||
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
||||
sidePanel.showCreature(cId);
|
||||
},
|
||||
[sidePanel.showCreature],
|
||||
);
|
||||
|
||||
const handleStartBulkImport = useCallback(
|
||||
(baseUrl: string) => {
|
||||
bulkImport.startImport(
|
||||
baseUrl,
|
||||
fetchAndCacheSource,
|
||||
isSourceCached,
|
||||
refreshCache,
|
||||
);
|
||||
},
|
||||
[bulkImport.startImport, fetchAndCacheSource, isSourceCached, refreshCache],
|
||||
);
|
||||
|
||||
const handleBulkImportDone = useCallback(() => {
|
||||
sidePanel.dismissPanel();
|
||||
bulkImport.reset();
|
||||
}, [sidePanel.dismissPanel, bulkImport.reset]);
|
||||
|
||||
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
|
||||
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
||||
|
||||
// Auto-scroll to the active combatant when the turn changes
|
||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||
// Auto-scroll to active combatant when turn changes
|
||||
const activeIndex = encounter.activeIndex;
|
||||
useEffect(() => {
|
||||
activeRowRef.current?.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 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 (!globalThis.matchMedia("(min-width: 1024px)").matches) return;
|
||||
const active = encounter.combatants[encounter.activeIndex];
|
||||
if (!active?.creatureId || !isLoaded) return;
|
||||
sidePanel.showCreature(active.creatureId);
|
||||
}, [
|
||||
encounter.activeIndex,
|
||||
encounter.combatants,
|
||||
isLoaded,
|
||||
sidePanel.showCreature,
|
||||
]);
|
||||
if (activeIndex >= 0) {
|
||||
activeRowRef.current?.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}, [activeIndex]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="relative mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-3 px-4">
|
||||
<div className="flex h-dvh flex-col">
|
||||
<div className="relative mx-auto flex min-h-0 w-full flex-1 flex-col gap-3 sm:max-w-2xl sm:px-4">
|
||||
{!!actionBarAnim.showTopBar && (
|
||||
<div
|
||||
className={`shrink-0 pt-8${actionBarAnim.topBarClass}`}
|
||||
className={cn("shrink-0 sm:pt-8", actionBarAnim.topBarClass)}
|
||||
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
||||
>
|
||||
<TurnNavigation
|
||||
encounter={encounter}
|
||||
onAdvanceTurn={advanceTurn}
|
||||
onRetreatTurn={retreatTurn}
|
||||
onClearEncounter={clearEncounter}
|
||||
/>
|
||||
<TurnNavigation />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty ? (
|
||||
/* Empty state — ActionBar centered */
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]">
|
||||
<div
|
||||
className={`w-full${actionBarAnim.risingClass}`}
|
||||
className={cn("w-full", actionBarAnim.risingClass)}
|
||||
onAnimationEnd={actionBarAnim.onRiseEnd}
|
||||
>
|
||||
<ActionBar
|
||||
onAddCombatant={addCombatant}
|
||||
onAddFromBestiary={handleAddFromBestiary}
|
||||
bestiarySearch={search}
|
||||
bestiaryLoaded={isLoaded}
|
||||
onViewStatBlock={handleViewStatBlock}
|
||||
onBulkImport={sidePanel.showBulkImport}
|
||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
||||
inputRef={actionBarInputRef}
|
||||
playerCharacters={playerCharacters}
|
||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||
onManagePlayers={() =>
|
||||
playerCharacterRef.current?.openManagement()
|
||||
}
|
||||
onRollAllInitiative={handleRollAllInitiative}
|
||||
showRollAllInitiative={hasCreatureCombatants}
|
||||
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||
onOpenSourceManager={sidePanel.showSourceManager}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Scrollable area — combatant list */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="flex flex-col px-2 py-2">
|
||||
{encounter.combatants.map((c, i) => (
|
||||
@@ -270,120 +79,56 @@ export function App() {
|
||||
ref={i === encounter.activeIndex ? activeRowRef : null}
|
||||
combatant={c}
|
||||
isActive={i === encounter.activeIndex}
|
||||
onRename={editCombatant}
|
||||
onSetInitiative={setInitiative}
|
||||
onRemove={removeCombatant}
|
||||
onSetHp={setHp}
|
||||
onAdjustHp={adjustHp}
|
||||
onSetAc={setAc}
|
||||
onToggleCondition={toggleCondition}
|
||||
onToggleConcentration={toggleConcentration}
|
||||
onShowStatBlock={
|
||||
c.creatureId
|
||||
? () => handleCombatantStatBlock(c.creatureId as string)
|
||||
: undefined
|
||||
}
|
||||
onRollInitiative={
|
||||
c.creatureId ? handleRollInitiative : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Bar — fixed at bottom */}
|
||||
<div
|
||||
className={`shrink-0 pb-8${actionBarAnim.settlingClass}`}
|
||||
className={cn("shrink-0 sm:pb-8", actionBarAnim.settlingClass)}
|
||||
onAnimationEnd={actionBarAnim.onSettleEnd}
|
||||
>
|
||||
<ActionBar
|
||||
onAddCombatant={addCombatant}
|
||||
onAddFromBestiary={handleAddFromBestiary}
|
||||
bestiarySearch={search}
|
||||
bestiaryLoaded={isLoaded}
|
||||
onViewStatBlock={handleViewStatBlock}
|
||||
onBulkImport={sidePanel.showBulkImport}
|
||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
||||
inputRef={actionBarInputRef}
|
||||
playerCharacters={playerCharacters}
|
||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||
onManagePlayers={() =>
|
||||
playerCharacterRef.current?.openManagement()
|
||||
}
|
||||
onRollAllInitiative={handleRollAllInitiative}
|
||||
showRollAllInitiative={hasCreatureCombatants}
|
||||
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||
onOpenSourceManager={sidePanel.showSourceManager}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pinned Stat Block Panel (left) */}
|
||||
{!!sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
|
||||
<StatBlockPanel
|
||||
creatureId={sidePanel.pinnedCreatureId}
|
||||
creature={pinnedCreature}
|
||||
isSourceCached={isSourceCached}
|
||||
fetchAndCacheSource={fetchAndCacheSource}
|
||||
uploadAndCacheSource={uploadAndCacheSource}
|
||||
refreshCache={refreshCache}
|
||||
panelRole="pinned"
|
||||
isCollapsed={false}
|
||||
onToggleCollapse={() => {}}
|
||||
onPin={() => {}}
|
||||
onUnpin={sidePanel.unpin}
|
||||
showPinButton={false}
|
||||
side="left"
|
||||
onDismiss={() => {}}
|
||||
/>
|
||||
<StatBlockPanel panelRole="pinned" side="left" />
|
||||
)}
|
||||
|
||||
{/* Browse Stat Block Panel (right) */}
|
||||
<StatBlockPanel
|
||||
creatureId={sidePanel.selectedCreatureId}
|
||||
creature={selectedCreature}
|
||||
isSourceCached={isSourceCached}
|
||||
fetchAndCacheSource={fetchAndCacheSource}
|
||||
uploadAndCacheSource={uploadAndCacheSource}
|
||||
refreshCache={refreshCache}
|
||||
panelRole="browse"
|
||||
isCollapsed={sidePanel.isRightPanelCollapsed}
|
||||
onToggleCollapse={sidePanel.toggleCollapse}
|
||||
onPin={sidePanel.togglePin}
|
||||
onUnpin={() => {}}
|
||||
showPinButton={sidePanel.isWideDesktop && !!selectedCreature}
|
||||
side="right"
|
||||
onDismiss={sidePanel.dismissPanel}
|
||||
bulkImportMode={sidePanel.bulkImportMode}
|
||||
bulkImportState={bulkImport.state}
|
||||
onStartBulkImport={handleStartBulkImport}
|
||||
onBulkImportDone={handleBulkImportDone}
|
||||
sourceManagerMode={sidePanel.sourceManagerMode}
|
||||
/>
|
||||
<StatBlockPanel panelRole="browse" side="right" />
|
||||
|
||||
<BulkImportToasts
|
||||
state={bulkImport.state}
|
||||
visible={!sidePanel.bulkImportMode || sidePanel.isRightPanelCollapsed}
|
||||
onReset={bulkImport.reset}
|
||||
/>
|
||||
<BulkImportToasts />
|
||||
|
||||
{rollSkippedCount > 0 && (
|
||||
{rolls.rollSkippedCount > 0 && (
|
||||
<Toast
|
||||
message={`${rollSkippedCount} skipped — bestiary source not loaded`}
|
||||
onDismiss={() => setRollSkippedCount(0)}
|
||||
message={`${rolls.rollSkippedCount} skipped — bestiary source not loaded`}
|
||||
onDismiss={rolls.dismissRollSkipped}
|
||||
autoDismissMs={4000}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PlayerCharacterSection
|
||||
ref={playerCharacterRef}
|
||||
characters={playerCharacters}
|
||||
onCreateCharacter={createPlayerCharacter}
|
||||
onEditCharacter={editPlayerCharacter}
|
||||
onDeleteCharacter={deletePlayerCharacter}
|
||||
{!!rolls.rollSingleSkipped && (
|
||||
<Toast
|
||||
message="Can't roll — bestiary source not loaded"
|
||||
onDismiss={rolls.dismissRollSingleSkipped}
|
||||
autoDismissMs={4000}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingsModal
|
||||
open={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
<PlayerCharacterSection ref={playerCharacterRef} />
|
||||
</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.js";
|
||||
import { AllProviders } from "./test-providers.js";
|
||||
|
||||
// Mock persistence — no localStorage interaction
|
||||
vi.mock("../persistence/encounter-storage.js", () => ({
|
||||
loadEncounter: () => null,
|
||||
saveEncounter: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../persistence/player-character-storage.js", () => ({
|
||||
loadPlayerCharacters: () => [],
|
||||
savePlayerCharacters: () => {},
|
||||
}));
|
||||
|
||||
// Mock bestiary — no IndexedDB or JSON index
|
||||
vi.mock("../adapters/bestiary-cache.js", () => ({
|
||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
||||
isSourceCached: () => Promise.resolve(false),
|
||||
cacheSource: () => Promise.resolve(),
|
||||
getCachedSources: () => Promise.resolve([]),
|
||||
clearSource: () => Promise.resolve(),
|
||||
clearAll: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("../adapters/bestiary-index-adapter.js", () => ({
|
||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||
getAllSourceCodes: () => [],
|
||||
getDefaultFetchUrl: () => "",
|
||||
getSourceDisplayName: (code: string) => code,
|
||||
}));
|
||||
|
||||
// DOM API stubs — jsdom doesn't implement these
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
async function addCombatant(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
name: string,
|
||||
opts?: { maxHp?: string },
|
||||
) {
|
||||
const inputs = screen.getAllByPlaceholderText("+ Add combatants");
|
||||
// biome-ignore lint/style/noNonNullAssertion: getAllBy always returns at least one
|
||||
const input = inputs.at(-1)!;
|
||||
await user.type(input, name);
|
||||
|
||||
if (opts?.maxHp) {
|
||||
const maxHpInput = screen.getByPlaceholderText("MaxHP");
|
||||
await user.type(maxHpInput, opts.maxHp);
|
||||
}
|
||||
|
||||
const addButton = screen.getByRole("button", { name: "Add" });
|
||||
await user.click(addButton);
|
||||
}
|
||||
|
||||
describe("App integration", () => {
|
||||
it("adds a combatant and removes it, returning to empty state", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />, { wrapper: AllProviders });
|
||||
|
||||
// Empty state: centered input visible, no TurnNavigation
|
||||
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
||||
expect(screen.queryByText("R1")).not.toBeInTheDocument();
|
||||
|
||||
// Add a combatant
|
||||
await addCombatant(user, "Goblin");
|
||||
|
||||
// Verify combatant appears and TurnNavigation shows
|
||||
expect(screen.getByRole("button", { name: "Goblin" })).toBeInTheDocument();
|
||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Goblin").length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Remove combatant via ConfirmButton (two clicks)
|
||||
const removeBtn = screen.getByRole("button", {
|
||||
name: "Remove combatant",
|
||||
});
|
||||
await user.click(removeBtn);
|
||||
const confirmBtn = screen.getByRole("button", {
|
||||
name: "Confirm remove combatant",
|
||||
});
|
||||
await user.click(confirmBtn);
|
||||
|
||||
// Back to empty state (R1 badge may linger due to exit animation in jsdom)
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Goblin" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("advances and retreats turns across two combatants", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />, { wrapper: AllProviders });
|
||||
|
||||
await addCombatant(user, "Fighter");
|
||||
await addCombatant(user, "Wizard");
|
||||
|
||||
// Initial state — R1, Fighter active (Previous turn disabled)
|
||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Previous turn" }),
|
||||
).toBeDisabled();
|
||||
|
||||
// Advance turn — Wizard becomes active
|
||||
await user.click(screen.getByRole("button", { name: "Next turn" }));
|
||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Previous turn" })).toBeEnabled();
|
||||
|
||||
// Advance again — wraps to R2, Fighter active
|
||||
await user.click(screen.getByRole("button", { name: "Next turn" }));
|
||||
expect(screen.getByText("R2")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Previous turn" })).toBeEnabled();
|
||||
|
||||
// Retreat — back to R1, Wizard active
|
||||
await user.click(screen.getByRole("button", { name: "Previous turn" }));
|
||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds a combatant with HP, applies damage, and shows unconscious state", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />, { wrapper: AllProviders });
|
||||
|
||||
await addCombatant(user, "Ogre", { maxHp: "59" });
|
||||
|
||||
// Verify HP displays — currentHp and maxHp both show "59"
|
||||
const hpButton = screen.getByRole("button", {
|
||||
name: "Current HP: 59 (healthy)",
|
||||
});
|
||||
expect(hpButton).toBeInTheDocument();
|
||||
|
||||
// Click currentHp to open HpAdjustPopover, apply full damage
|
||||
await user.click(hpButton);
|
||||
const hpInput = screen.getByPlaceholderText("HP");
|
||||
expect(hpInput).toBeInTheDocument();
|
||||
await user.type(hpInput, "59");
|
||||
await user.click(screen.getByRole("button", { name: "Apply damage" }));
|
||||
|
||||
// Verify HP decreased to 0 and unconscious state
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Current HP: 0 (unconscious)" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -4,11 +4,33 @@ import "@testing-library/jest-dom/vitest";
|
||||
import type { Creature, CreatureId } from "@initiative/domain";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { StatBlockPanel } from "../components/stat-block-panel";
|
||||
|
||||
// Mock the context modules
|
||||
vi.mock("../contexts/side-panel-context.js", () => ({
|
||||
useSidePanelContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock adapters to avoid IndexedDB
|
||||
vi.mock("../adapters/bestiary-index-adapter.js", () => ({
|
||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||
getAllSourceCodes: () => [],
|
||||
getDefaultFetchUrl: () => "",
|
||||
getSourceDisplayName: (code: string) => code,
|
||||
}));
|
||||
|
||||
import { StatBlockPanel } from "../components/stat-block-panel.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
|
||||
const mockUseSidePanelContext = vi.mocked(useSidePanelContext);
|
||||
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
|
||||
|
||||
const CLOSE_REGEX = /close/i;
|
||||
const COLLAPSE_REGEX = /collapse/i;
|
||||
|
||||
const CREATURE_ID = "srd:goblin" as CreatureId;
|
||||
const CREATURE: Creature = {
|
||||
id: CREATURE_ID,
|
||||
@@ -44,41 +66,65 @@ function mockMatchMedia(matches: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
interface PanelProps {
|
||||
interface PanelOverrides {
|
||||
creatureId?: CreatureId | null;
|
||||
creature?: Creature | null;
|
||||
panelRole?: "browse" | "pinned";
|
||||
isCollapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
onPin?: () => void;
|
||||
onUnpin?: () => void;
|
||||
showPinButton?: boolean;
|
||||
side?: "left" | "right";
|
||||
onDismiss?: () => void;
|
||||
bulkImportMode?: boolean;
|
||||
}
|
||||
|
||||
function renderPanel(overrides: PanelProps = {}) {
|
||||
const props = {
|
||||
creatureId: CREATURE_ID,
|
||||
creature: CREATURE,
|
||||
function setupMocks(overrides: PanelOverrides = {}) {
|
||||
const panelRole = overrides.panelRole ?? "browse";
|
||||
const creatureId = overrides.creatureId ?? CREATURE_ID;
|
||||
const creature = overrides.creature ?? CREATURE;
|
||||
const isCollapsed = overrides.isCollapsed ?? false;
|
||||
|
||||
const onToggleCollapse = vi.fn();
|
||||
const onPin = vi.fn();
|
||||
const onUnpin = vi.fn();
|
||||
const onDismiss = vi.fn();
|
||||
|
||||
mockUseSidePanelContext.mockReturnValue({
|
||||
selectedCreatureId: panelRole === "browse" ? creatureId : null,
|
||||
pinnedCreatureId: panelRole === "pinned" ? creatureId : null,
|
||||
isRightPanelCollapsed: panelRole === "browse" ? isCollapsed : false,
|
||||
isWideDesktop: false,
|
||||
bulkImportMode: overrides.bulkImportMode ?? false,
|
||||
sourceManagerMode: false,
|
||||
panelView: creatureId
|
||||
? { mode: "creature" as const, creatureId }
|
||||
: { mode: "closed" as const },
|
||||
showCreature: vi.fn(),
|
||||
updateCreature: vi.fn(),
|
||||
showBulkImport: vi.fn(),
|
||||
showSourceManager: vi.fn(),
|
||||
dismissPanel: onDismiss,
|
||||
toggleCollapse: onToggleCollapse,
|
||||
togglePin: onPin,
|
||||
unpin: onUnpin,
|
||||
} as ReturnType<typeof useSidePanelContext>);
|
||||
|
||||
mockUseBestiaryContext.mockReturnValue({
|
||||
getCreature: (id: CreatureId) => (id === creatureId ? creature : undefined),
|
||||
isSourceCached: vi.fn().mockResolvedValue(true),
|
||||
search: vi.fn().mockReturnValue([]),
|
||||
isLoaded: true,
|
||||
fetchAndCacheSource: vi.fn(),
|
||||
uploadAndCacheSource: vi.fn(),
|
||||
refreshCache: vi.fn(),
|
||||
panelRole: "browse" as const,
|
||||
isCollapsed: false,
|
||||
onToggleCollapse: vi.fn(),
|
||||
onPin: vi.fn(),
|
||||
onUnpin: vi.fn(),
|
||||
showPinButton: false,
|
||||
side: "right" as const,
|
||||
onDismiss: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
} as ReturnType<typeof useBestiaryContext>);
|
||||
|
||||
render(<StatBlockPanel {...props} />);
|
||||
return props;
|
||||
return { onToggleCollapse, onPin, onUnpin, onDismiss };
|
||||
}
|
||||
|
||||
function renderPanel(overrides: PanelOverrides = {}) {
|
||||
const callbacks = setupMocks(overrides);
|
||||
const panelRole = overrides.panelRole ?? "browse";
|
||||
const side = overrides.side ?? (panelRole === "pinned" ? "left" : "right");
|
||||
render(<StatBlockPanel panelRole={panelRole} side={side} />);
|
||||
return callbacks;
|
||||
}
|
||||
|
||||
describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
||||
@@ -113,19 +159,19 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
||||
});
|
||||
|
||||
it("calls onToggleCollapse when collapse button is clicked", () => {
|
||||
const props = renderPanel();
|
||||
const callbacks = renderPanel();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
||||
);
|
||||
expect(props.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||
expect(callbacks.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onToggleCollapse when collapsed tab is clicked", () => {
|
||||
const props = renderPanel({ isCollapsed: true });
|
||||
const callbacks = renderPanel({ isCollapsed: true });
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Expand stat block panel" }),
|
||||
);
|
||||
expect(props.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||
expect(callbacks.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("applies translate-x class when collapsed (right side)", () => {
|
||||
@@ -163,53 +209,58 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
||||
});
|
||||
|
||||
it("calls onDismiss when backdrop is clicked on mobile", () => {
|
||||
const props = renderPanel();
|
||||
const callbacks = renderPanel();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close stat block" }));
|
||||
expect(props.onDismiss).toHaveBeenCalledTimes(1);
|
||||
expect(callbacks.onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not render pinned panel on mobile", () => {
|
||||
const { container } = render(
|
||||
<StatBlockPanel
|
||||
creatureId={CREATURE_ID}
|
||||
creature={CREATURE}
|
||||
isSourceCached={vi.fn().mockResolvedValue(true)}
|
||||
fetchAndCacheSource={vi.fn()}
|
||||
uploadAndCacheSource={vi.fn()}
|
||||
refreshCache={vi.fn()}
|
||||
panelRole="pinned"
|
||||
isCollapsed={false}
|
||||
onToggleCollapse={vi.fn()}
|
||||
onPin={vi.fn()}
|
||||
onUnpin={vi.fn()}
|
||||
showPinButton={false}
|
||||
side="left"
|
||||
onDismiss={vi.fn()}
|
||||
/>,
|
||||
(() => {
|
||||
setupMocks({ panelRole: "pinned" });
|
||||
return <StatBlockPanel panelRole="pinned" side="left" />;
|
||||
})(),
|
||||
);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("US2: Pin and Unpin", () => {
|
||||
it("shows pin button when showPinButton is true on desktop", () => {
|
||||
renderPanel({ showPinButton: true });
|
||||
it("shows pin button when isWideDesktop is true on desktop", () => {
|
||||
setupMocks();
|
||||
// Override to set isWideDesktop
|
||||
const ctx = mockUseSidePanelContext.mock.results[0]?.value as ReturnType<
|
||||
typeof useSidePanelContext
|
||||
>;
|
||||
mockUseSidePanelContext.mockReturnValue({
|
||||
...ctx,
|
||||
isWideDesktop: true,
|
||||
});
|
||||
render(<StatBlockPanel panelRole="browse" side="right" />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Pin creature" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides pin button when showPinButton is false", () => {
|
||||
renderPanel({ showPinButton: false });
|
||||
it("hides pin button when isWideDesktop is false", () => {
|
||||
renderPanel();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Pin creature" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onPin when pin button is clicked", () => {
|
||||
const props = renderPanel({ showPinButton: true });
|
||||
setupMocks();
|
||||
const ctx = mockUseSidePanelContext.mock.results[0]?.value as ReturnType<
|
||||
typeof useSidePanelContext
|
||||
>;
|
||||
mockUseSidePanelContext.mockReturnValue({
|
||||
...ctx,
|
||||
isWideDesktop: true,
|
||||
});
|
||||
render(<StatBlockPanel panelRole="browse" side="right" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Pin creature" }));
|
||||
expect(props.onPin).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.togglePin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows unpin button for pinned role", () => {
|
||||
@@ -220,9 +271,9 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
||||
});
|
||||
|
||||
it("calls onUnpin when unpin button is clicked", () => {
|
||||
const props = renderPanel({ panelRole: "pinned", side: "left" });
|
||||
const callbacks = renderPanel({ panelRole: "pinned", side: "left" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Unpin creature" }));
|
||||
expect(props.onUnpin).toHaveBeenCalledTimes(1);
|
||||
expect(callbacks.onUnpin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("positions pinned panel on the left side", () => {
|
||||
@@ -255,7 +306,7 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
||||
});
|
||||
|
||||
it("pinned panel is always expanded (no translate offset)", () => {
|
||||
renderPanel({ panelRole: "pinned", side: "left", isCollapsed: false });
|
||||
renderPanel({ panelRole: "pinned", side: "left" });
|
||||
const unpinBtn = screen.getByRole("button", {
|
||||
name: "Unpin creature",
|
||||
});
|
||||
|
||||
31
apps/web/src/__tests__/test-providers.tsx
Normal file
31
apps/web/src/__tests__/test-providers.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
BestiaryProvider,
|
||||
BulkImportProvider,
|
||||
EncounterProvider,
|
||||
InitiativeRollsProvider,
|
||||
PlayerCharactersProvider,
|
||||
RulesEditionProvider,
|
||||
SidePanelProvider,
|
||||
ThemeProvider,
|
||||
} from "../contexts/index.js";
|
||||
|
||||
export function AllProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<RulesEditionProvider>
|
||||
<EncounterProvider>
|
||||
<BestiaryProvider>
|
||||
<PlayerCharactersProvider>
|
||||
<BulkImportProvider>
|
||||
<SidePanelProvider>
|
||||
<InitiativeRollsProvider>{children}</InitiativeRollsProvider>
|
||||
</SidePanelProvider>
|
||||
</BulkImportProvider>
|
||||
</PlayerCharactersProvider>
|
||||
</BestiaryProvider>
|
||||
</EncounterProvider>
|
||||
</RulesEditionProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -300,6 +300,44 @@ describe("normalizeBestiary", () => {
|
||||
expect(creatures[0].proficiencyBonus).toBe(6);
|
||||
});
|
||||
|
||||
it("normalizes pre-2024 {@atk mw} tags to full attack type text", () => {
|
||||
const raw = {
|
||||
monster: [
|
||||
{
|
||||
name: "Adult Black Dragon",
|
||||
source: "MM",
|
||||
size: ["H"],
|
||||
type: "dragon",
|
||||
ac: [19],
|
||||
hp: { average: 195, formula: "17d12 + 85" },
|
||||
speed: { walk: 40, fly: 80, swim: 40 },
|
||||
str: 23,
|
||||
dex: 14,
|
||||
con: 21,
|
||||
int: 14,
|
||||
wis: 13,
|
||||
cha: 17,
|
||||
passive: 21,
|
||||
cr: "14",
|
||||
action: [
|
||||
{
|
||||
name: "Bite",
|
||||
entries: [
|
||||
"{@atk mw} {@hit 11} to hit, reach 10 ft., one target. {@h}17 ({@damage 2d10 + 6}) piercing damage plus 4 ({@damage 1d8}) acid damage.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const creatures = normalizeBestiary(raw);
|
||||
const bite = creatures[0].actions?.[0];
|
||||
expect(bite?.text).toContain("Melee Weapon Attack:");
|
||||
expect(bite?.text).not.toContain("mw");
|
||||
expect(bite?.text).not.toContain("{@");
|
||||
});
|
||||
|
||||
it("handles fly speed with hover condition", () => {
|
||||
const raw = {
|
||||
monster: [
|
||||
|
||||
@@ -50,6 +50,26 @@ describe("stripTags", () => {
|
||||
expect(stripTags("{@atkr m,r}")).toBe("Melee or Ranged Attack Roll:");
|
||||
});
|
||||
|
||||
it("strips {@atk mw} to Melee Weapon Attack:", () => {
|
||||
expect(stripTags("{@atk mw}")).toBe("Melee Weapon Attack:");
|
||||
});
|
||||
|
||||
it("strips {@atk rw} to Ranged Weapon Attack:", () => {
|
||||
expect(stripTags("{@atk rw}")).toBe("Ranged Weapon Attack:");
|
||||
});
|
||||
|
||||
it("strips {@atk ms} to Melee Spell Attack:", () => {
|
||||
expect(stripTags("{@atk ms}")).toBe("Melee Spell Attack:");
|
||||
});
|
||||
|
||||
it("strips {@atk rs} to Ranged Spell Attack:", () => {
|
||||
expect(stripTags("{@atk rs}")).toBe("Ranged Spell Attack:");
|
||||
});
|
||||
|
||||
it("strips {@atk mw,rw} to Melee or Ranged Weapon Attack:", () => {
|
||||
expect(stripTags("{@atk mw,rw}")).toBe("Melee or Ranged Weapon Attack:");
|
||||
});
|
||||
|
||||
it("strips {@recharge 5} to (Recharge 5-6)", () => {
|
||||
expect(stripTags("{@recharge 5}")).toBe("(Recharge 5-6)");
|
||||
});
|
||||
|
||||
@@ -51,6 +51,7 @@ interface RawMonster {
|
||||
legendaryHeader?: string[];
|
||||
spellcasting?: RawSpellcasting[];
|
||||
initiative?: { proficiency?: number };
|
||||
_copy?: unknown;
|
||||
}
|
||||
|
||||
interface RawEntry {
|
||||
@@ -385,8 +386,7 @@ export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
|
||||
// Filter out _copy entries (reference another source's monster) and
|
||||
// monsters missing required fields (ac, hp, size, type)
|
||||
const monsters = raw.monster.filter((m) => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: raw JSON may have _copy field
|
||||
if ((m as any)._copy) return false;
|
||||
if (m._copy) return false;
|
||||
return (
|
||||
Array.isArray(m.ac) &&
|
||||
m.ac.length > 0 &&
|
||||
|
||||
@@ -3,7 +3,7 @@ import { type IDBPDatabase, openDB } from "idb";
|
||||
|
||||
const DB_NAME = "initiative-bestiary";
|
||||
const STORE_NAME = "sources";
|
||||
const DB_VERSION = 1;
|
||||
const DB_VERSION = 2;
|
||||
|
||||
export interface CachedSourceInfo {
|
||||
readonly sourceCode: string;
|
||||
@@ -32,12 +32,16 @@ async function getDb(): Promise<IDBPDatabase | null> {
|
||||
|
||||
try {
|
||||
db = await openDB(DB_NAME, DB_VERSION, {
|
||||
upgrade(database) {
|
||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||
upgrade(database, oldVersion, _newVersion, transaction) {
|
||||
if (oldVersion < 1) {
|
||||
database.createObjectStore(STORE_NAME, {
|
||||
keyPath: "sourceCode",
|
||||
});
|
||||
}
|
||||
if (oldVersion < 2 && database.objectStoreNames.contains(STORE_NAME)) {
|
||||
// Clear cached creatures to pick up improved tag processing
|
||||
transaction.objectStore(STORE_NAME).clear();
|
||||
}
|
||||
},
|
||||
});
|
||||
return db;
|
||||
|
||||
@@ -14,6 +14,15 @@ const ATKR_MAP: Record<string, string> = {
|
||||
"r,m": "Melee or Ranged Attack Roll:",
|
||||
};
|
||||
|
||||
const ATK_MAP: Record<string, string> = {
|
||||
mw: "Melee Weapon Attack:",
|
||||
rw: "Ranged Weapon Attack:",
|
||||
ms: "Melee Spell Attack:",
|
||||
rs: "Ranged Spell Attack:",
|
||||
"mw,rw": "Melee or Ranged Weapon Attack:",
|
||||
"rw,mw": "Melee or Ranged Weapon Attack:",
|
||||
};
|
||||
|
||||
/**
|
||||
* Strips 5etools {@tag ...} markup from text, converting to plain readable text.
|
||||
*
|
||||
@@ -51,11 +60,16 @@ export function stripTags(text: string): string {
|
||||
// {@hit N} → "+N"
|
||||
result = result.replaceAll(/\{@hit\s+(\d+)\}/g, "+$1");
|
||||
|
||||
// {@atkr type} → mapped attack roll text
|
||||
// {@atkr type} → mapped attack roll text (2024 rules)
|
||||
result = result.replaceAll(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
|
||||
return ATKR_MAP[type.trim()] ?? "Attack Roll:";
|
||||
});
|
||||
|
||||
// {@atk type} → mapped attack type text (pre-2024 data)
|
||||
result = result.replaceAll(/\{@atk\s+([^}]+)\}/g, (_, type: string) => {
|
||||
return ATK_MAP[type.trim()] ?? "Attack:";
|
||||
});
|
||||
|
||||
// {@actSave ability} → "Ability saving throw"
|
||||
result = result.replaceAll(
|
||||
/\{@actSave\s+([^}]+)\}/g,
|
||||
|
||||
@@ -3,21 +3,59 @@ 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";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { ActionBar } from "../action-bar.js";
|
||||
|
||||
// Mock persistence — no localStorage interaction
|
||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||
loadEncounter: () => null,
|
||||
saveEncounter: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
||||
loadPlayerCharacters: () => [],
|
||||
savePlayerCharacters: () => {},
|
||||
}));
|
||||
|
||||
// Mock bestiary — no IndexedDB or JSON index
|
||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
||||
isSourceCached: () => Promise.resolve(false),
|
||||
cacheSource: () => Promise.resolve(),
|
||||
getCachedSources: () => Promise.resolve([]),
|
||||
clearSource: () => Promise.resolve(),
|
||||
clearAll: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||
getAllSourceCodes: () => [],
|
||||
getDefaultFetchUrl: () => "",
|
||||
getSourceDisplayName: (code: string) => code,
|
||||
}));
|
||||
|
||||
// DOM API stubs — jsdom doesn't implement these
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
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} />);
|
||||
function renderBar(props: Partial<Parameters<typeof ActionBar>[0]> = {}) {
|
||||
return render(<ActionBar {...props} />, { wrapper: AllProviders });
|
||||
}
|
||||
|
||||
describe("ActionBar", () => {
|
||||
@@ -26,26 +64,26 @@ describe("ActionBar", () => {
|
||||
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submitting with a name calls onAddCombatant", async () => {
|
||||
it("submitting with a name adds a combatant", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAddCombatant = vi.fn();
|
||||
renderBar({ onAddCombatant });
|
||||
renderBar();
|
||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||
await user.type(input, "Goblin");
|
||||
// The Add button appears when name >= 2 chars and no suggestions
|
||||
const addButton = screen.getByRole("button", { name: "Add" });
|
||||
await user.click(addButton);
|
||||
expect(onAddCombatant).toHaveBeenCalledWith("Goblin", undefined);
|
||||
// Input is cleared after adding (context handles the state)
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("submitting with empty name does nothing", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAddCombatant = vi.fn();
|
||||
renderBar({ onAddCombatant });
|
||||
renderBar();
|
||||
// Submit the form directly (Enter on empty input)
|
||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||
await user.type(input, "{Enter}");
|
||||
expect(onAddCombatant).not.toHaveBeenCalled();
|
||||
// Input stays empty, no error
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("shows custom fields (Init, AC, MaxHP) when name >= 2 chars and no bestiary suggestions", async () => {
|
||||
@@ -66,23 +104,18 @@ describe("ActionBar", () => {
|
||||
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 });
|
||||
it("does not show roll all initiative button when no creature combatants", () => {
|
||||
renderBar();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Roll all initiative" }),
|
||||
).toBeInTheDocument();
|
||||
screen.queryByRole("button", { name: "Roll all initiative" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("roll all initiative button is disabled when rollAllInitiativeDisabled is true", () => {
|
||||
const onRollAllInitiative = vi.fn();
|
||||
renderBar({
|
||||
showRollAllInitiative: true,
|
||||
onRollAllInitiative,
|
||||
rollAllInitiativeDisabled: true,
|
||||
});
|
||||
it("shows overflow menu items", () => {
|
||||
renderBar({ onManagePlayers: vi.fn() });
|
||||
// The overflow menu should be present (it contains Player Characters etc.)
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Roll all initiative" }),
|
||||
).toBeDisabled();
|
||||
screen.getByRole("button", { name: "More actions" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,33 +1,67 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { combatantId } from "@initiative/domain";
|
||||
import { type CreatureId, 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";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { CombatantRow } from "../combatant-row.js";
|
||||
import { PLAYER_COLOR_HEX } from "../player-icon-map.js";
|
||||
|
||||
const TEMP_HP_REGEX = /^\+\d/;
|
||||
|
||||
// Mock persistence — no localStorage interaction
|
||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||
loadEncounter: () => null,
|
||||
saveEncounter: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
||||
loadPlayerCharacters: () => [],
|
||||
savePlayerCharacters: () => {},
|
||||
}));
|
||||
|
||||
// Mock bestiary — no IndexedDB or JSON index
|
||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
||||
isSourceCached: () => Promise.resolve(false),
|
||||
cacheSource: () => Promise.resolve(),
|
||||
getCachedSources: () => Promise.resolve([]),
|
||||
clearSource: () => Promise.resolve(),
|
||||
clearAll: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||
getAllSourceCodes: () => [],
|
||||
getDefaultFetchUrl: () => "",
|
||||
getSourceDisplayName: (code: string) => code,
|
||||
}));
|
||||
|
||||
// DOM API stubs
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
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 ?? {
|
||||
@@ -38,15 +72,13 @@ function renderRow(
|
||||
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} />);
|
||||
return render(
|
||||
<CombatantRow
|
||||
combatant={combatant}
|
||||
isActive={overrides.isActive ?? false}
|
||||
/>,
|
||||
{ wrapper: AllProviders },
|
||||
);
|
||||
}
|
||||
|
||||
describe("CombatantRow", () => {
|
||||
@@ -75,7 +107,7 @@ describe("CombatantRow", () => {
|
||||
it("active combatant gets active border styling", () => {
|
||||
const { container } = renderRow({ isActive: true });
|
||||
const row = container.firstElementChild;
|
||||
expect(row?.className).toContain("border-l-accent");
|
||||
expect(row?.className).toContain("border-active-row-border");
|
||||
});
|
||||
|
||||
it("unconscious combatant (currentHp === 0) gets dimmed styling", () => {
|
||||
@@ -93,14 +125,14 @@ describe("CombatantRow", () => {
|
||||
expect(nameContainer).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows '--' for current HP when no maxHp is set", () => {
|
||||
it("shows 'Max' placeholder when no maxHp is set", () => {
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
},
|
||||
});
|
||||
expect(screen.getByLabelText("No HP set")).toBeInTheDocument();
|
||||
expect(screen.getByText("Max")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows concentration icon when isConcentrating is true", () => {
|
||||
@@ -132,10 +164,9 @@ describe("CombatantRow", () => {
|
||||
expect(svgIcon).toHaveStyle({ color: PLAYER_COLOR_HEX.red });
|
||||
});
|
||||
|
||||
it("remove button calls onRemove after confirmation", async () => {
|
||||
it("remove button removes after confirmation", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRemove = vi.fn();
|
||||
renderRow({ onRemove });
|
||||
renderRow();
|
||||
const removeBtn = screen.getByRole("button", {
|
||||
name: "Remove combatant",
|
||||
});
|
||||
@@ -146,19 +177,124 @@ describe("CombatantRow", () => {
|
||||
name: "Confirm remove combatant",
|
||||
});
|
||||
await user.click(confirmBtn);
|
||||
expect(onRemove).toHaveBeenCalledWith(combatantId("1"));
|
||||
// After confirming, the button returns to its initial state
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Confirm remove combatant" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows d20 roll button when initiative is undefined and onRollInitiative is provided", () => {
|
||||
it("shows d20 roll button when initiative is undefined and combatant has creatureId", () => {
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
creatureId: "srd:goblin" as CreatureId,
|
||||
},
|
||||
onRollInitiative: vi.fn(),
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Roll initiative" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("concentration pulse", () => {
|
||||
it("pulses when currentHp drops on a concentrating combatant", () => {
|
||||
const combatant = {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
maxHp: 20,
|
||||
currentHp: 15,
|
||||
isConcentrating: true,
|
||||
};
|
||||
const { rerender, container } = renderRow({ combatant });
|
||||
rerender(
|
||||
<CombatantRow
|
||||
combatant={{ ...combatant, currentHp: 10 }}
|
||||
isActive={false}
|
||||
/>,
|
||||
);
|
||||
const row = container.firstElementChild;
|
||||
expect(row?.className).toContain("animate-concentration-pulse");
|
||||
});
|
||||
|
||||
it("does not pulse when not concentrating", () => {
|
||||
const combatant = {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
maxHp: 20,
|
||||
currentHp: 15,
|
||||
isConcentrating: false,
|
||||
};
|
||||
const { rerender, container } = renderRow({ combatant });
|
||||
rerender(
|
||||
<CombatantRow
|
||||
combatant={{ ...combatant, currentHp: 10 }}
|
||||
isActive={false}
|
||||
/>,
|
||||
);
|
||||
const row = container.firstElementChild;
|
||||
expect(row?.className).not.toContain("animate-concentration-pulse");
|
||||
});
|
||||
|
||||
it("pulses when temp HP absorbs all damage on a concentrating combatant", () => {
|
||||
const combatant = {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
maxHp: 20,
|
||||
currentHp: 15,
|
||||
tempHp: 8,
|
||||
isConcentrating: true,
|
||||
};
|
||||
const { rerender, container } = renderRow({ combatant });
|
||||
// Temp HP absorbs all damage, currentHp unchanged
|
||||
rerender(
|
||||
<CombatantRow
|
||||
combatant={{ ...combatant, tempHp: 3 }}
|
||||
isActive={false}
|
||||
/>,
|
||||
);
|
||||
const row = container.firstElementChild;
|
||||
expect(row?.className).toContain("animate-concentration-pulse");
|
||||
});
|
||||
});
|
||||
|
||||
describe("temp HP display", () => {
|
||||
it("shows +N when combatant has temp HP", () => {
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
maxHp: 20,
|
||||
currentHp: 15,
|
||||
tempHp: 5,
|
||||
},
|
||||
});
|
||||
expect(screen.getByText("+5")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show +N when combatant has no temp HP", () => {
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
maxHp: 20,
|
||||
currentHp: 15,
|
||||
},
|
||||
});
|
||||
expect(screen.queryByText(TEMP_HP_REGEX)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("temp HP display uses cyan color", () => {
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
maxHp: 20,
|
||||
currentHp: 15,
|
||||
tempHp: 8,
|
||||
},
|
||||
});
|
||||
const tempHpEl = screen.getByText("+8");
|
||||
expect(tempHpEl.className).toContain("text-cyan-400");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,9 @@ import "@testing-library/jest-dom/vitest";
|
||||
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRef, type RefObject } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { RulesEditionProvider } from "../../contexts/index.js";
|
||||
import { ConditionPicker } from "../condition-picker";
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -18,12 +20,19 @@ function renderPicker(
|
||||
) {
|
||||
const onToggle = overrides.onToggle ?? vi.fn();
|
||||
const onClose = overrides.onClose ?? vi.fn();
|
||||
const anchorRef = createRef<HTMLElement>() as RefObject<HTMLElement>;
|
||||
const anchor = document.createElement("div");
|
||||
document.body.appendChild(anchor);
|
||||
(anchorRef as { current: HTMLElement }).current = anchor;
|
||||
const result = render(
|
||||
<ConditionPicker
|
||||
activeConditions={overrides.activeConditions ?? []}
|
||||
onToggle={onToggle}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
<RulesEditionProvider>
|
||||
<ConditionPicker
|
||||
anchorRef={anchorRef}
|
||||
activeConditions={overrides.activeConditions ?? []}
|
||||
onToggle={onToggle}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</RulesEditionProvider>,
|
||||
);
|
||||
return { ...result, onToggle, onClose };
|
||||
}
|
||||
|
||||
@@ -11,15 +11,21 @@ afterEach(cleanup);
|
||||
function renderPopover(
|
||||
overrides: Partial<{
|
||||
onAdjust: (delta: number) => void;
|
||||
onSetTempHp: (value: number) => void;
|
||||
onClose: () => void;
|
||||
}> = {},
|
||||
) {
|
||||
const onAdjust = overrides.onAdjust ?? vi.fn();
|
||||
const onSetTempHp = overrides.onSetTempHp ?? vi.fn();
|
||||
const onClose = overrides.onClose ?? vi.fn();
|
||||
const result = render(
|
||||
<HpAdjustPopover onAdjust={onAdjust} onClose={onClose} />,
|
||||
<HpAdjustPopover
|
||||
onAdjust={onAdjust}
|
||||
onSetTempHp={onSetTempHp}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
return { ...result, onAdjust, onClose };
|
||||
return { ...result, onAdjust, onSetTempHp, onClose };
|
||||
}
|
||||
|
||||
describe("HpAdjustPopover", () => {
|
||||
@@ -112,4 +118,31 @@ describe("HpAdjustPopover", () => {
|
||||
await user.type(input, "12abc34");
|
||||
expect(input).toHaveValue("1234");
|
||||
});
|
||||
|
||||
describe("temp HP", () => {
|
||||
it("shield button calls onSetTempHp with entered value and closes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSetTempHp, onClose } = renderPopover();
|
||||
await user.type(screen.getByPlaceholderText("HP"), "8");
|
||||
await user.click(screen.getByRole("button", { name: "Set temp HP" }));
|
||||
expect(onSetTempHp).toHaveBeenCalledWith(8);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shield button is disabled when input is empty", () => {
|
||||
renderPopover();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Set temp HP" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shield button is disabled when input is '0'", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPopover();
|
||||
await user.type(screen.getByPlaceholderText("HP"), "0");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Set temp HP" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,28 +11,51 @@ vi.mock("../../adapters/bestiary-cache.js", () => ({
|
||||
clearAll: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the context module
|
||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as bestiaryCache from "../../adapters/bestiary-cache.js";
|
||||
import { SourceManager } from "../source-manager";
|
||||
import { useBestiaryContext } from "../../contexts/bestiary-context.js";
|
||||
import { SourceManager } from "../source-manager.js";
|
||||
|
||||
const mockGetCachedSources = vi.mocked(bestiaryCache.getCachedSources);
|
||||
const mockClearSource = vi.mocked(bestiaryCache.clearSource);
|
||||
const mockClearAll = vi.mocked(bestiaryCache.clearAll);
|
||||
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function setupMockContext() {
|
||||
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||
mockUseBestiaryContext.mockReturnValue({
|
||||
refreshCache,
|
||||
search: vi.fn().mockReturnValue([]),
|
||||
getCreature: vi.fn(),
|
||||
isLoaded: true,
|
||||
isSourceCached: vi.fn().mockResolvedValue(false),
|
||||
fetchAndCacheSource: vi.fn(),
|
||||
uploadAndCacheSource: vi.fn(),
|
||||
} as ReturnType<typeof useBestiaryContext>);
|
||||
return { refreshCache };
|
||||
}
|
||||
|
||||
describe("SourceManager", () => {
|
||||
it("shows 'No cached sources' empty state when no sources", async () => {
|
||||
setupMockContext();
|
||||
mockGetCachedSources.mockResolvedValue([]);
|
||||
render(<SourceManager onCacheCleared={vi.fn()} />);
|
||||
render(<SourceManager />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("lists cached sources with display name and creature count", async () => {
|
||||
setupMockContext();
|
||||
mockGetCachedSources.mockResolvedValue([
|
||||
{
|
||||
sourceCode: "mm",
|
||||
@@ -47,7 +70,7 @@ describe("SourceManager", () => {
|
||||
cachedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
render(<SourceManager onCacheCleared={vi.fn()} />);
|
||||
render(<SourceManager />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||
});
|
||||
@@ -56,9 +79,9 @@ describe("SourceManager", () => {
|
||||
expect(screen.getByText("100 creatures")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Clear All button calls cache clear and onCacheCleared", async () => {
|
||||
it("Clear All button calls cache clear and refreshCache", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCacheCleared = vi.fn();
|
||||
const { refreshCache } = setupMockContext();
|
||||
mockGetCachedSources
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
@@ -70,7 +93,7 @@ describe("SourceManager", () => {
|
||||
])
|
||||
.mockResolvedValue([]);
|
||||
mockClearAll.mockResolvedValue(undefined);
|
||||
render(<SourceManager onCacheCleared={onCacheCleared} />);
|
||||
render(<SourceManager />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||
@@ -80,12 +103,12 @@ describe("SourceManager", () => {
|
||||
await waitFor(() => {
|
||||
expect(mockClearAll).toHaveBeenCalled();
|
||||
});
|
||||
expect(onCacheCleared).toHaveBeenCalled();
|
||||
expect(refreshCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("individual source delete button calls clear for that source", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCacheCleared = vi.fn();
|
||||
const { refreshCache } = setupMockContext();
|
||||
mockGetCachedSources
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
@@ -111,7 +134,7 @@ describe("SourceManager", () => {
|
||||
]);
|
||||
mockClearSource.mockResolvedValue(undefined);
|
||||
|
||||
render(<SourceManager onCacheCleared={onCacheCleared} />);
|
||||
render(<SourceManager />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||
});
|
||||
@@ -122,6 +145,6 @@ describe("SourceManager", () => {
|
||||
await waitFor(() => {
|
||||
expect(mockClearSource).toHaveBeenCalledWith("mm");
|
||||
});
|
||||
expect(onCacheCleared).toHaveBeenCalled();
|
||||
expect(refreshCache).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,23 @@ import type { Encounter } from "@initiative/domain";
|
||||
import { combatantId } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TurnNavigation } from "../turn-navigation";
|
||||
|
||||
afterEach(cleanup);
|
||||
// Mock the context module
|
||||
vi.mock("../../contexts/encounter-context.js", () => ({
|
||||
useEncounterContext: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderNav(overrides: Partial<Encounter> = {}) {
|
||||
import { useEncounterContext } from "../../contexts/encounter-context.js";
|
||||
import { TurnNavigation } from "../turn-navigation.js";
|
||||
|
||||
const mockUseEncounterContext = vi.mocked(useEncounterContext);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function mockContext(overrides: Partial<Encounter> = {}) {
|
||||
const encounter: Encounter = {
|
||||
combatants: [
|
||||
{ id: combatantId("1"), name: "Goblin" },
|
||||
@@ -20,14 +32,40 @@ function renderNav(overrides: Partial<Encounter> = {}) {
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return render(
|
||||
<TurnNavigation
|
||||
encounter={encounter}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
/>,
|
||||
const value = {
|
||||
encounter,
|
||||
advanceTurn: vi.fn(),
|
||||
retreatTurn: vi.fn(),
|
||||
clearEncounter: vi.fn(),
|
||||
isEmpty: encounter.combatants.length === 0,
|
||||
hasCreatureCombatants: false,
|
||||
canRollAllInitiative: false,
|
||||
addCombatant: vi.fn(),
|
||||
removeCombatant: vi.fn(),
|
||||
editCombatant: vi.fn(),
|
||||
setInitiative: vi.fn(),
|
||||
setHp: vi.fn(),
|
||||
adjustHp: vi.fn(),
|
||||
setTempHp: vi.fn(),
|
||||
hasTempHp: false,
|
||||
setAc: vi.fn(),
|
||||
toggleCondition: vi.fn(),
|
||||
toggleConcentration: vi.fn(),
|
||||
addFromBestiary: vi.fn(),
|
||||
addFromPlayerCharacter: vi.fn(),
|
||||
makeStore: vi.fn(),
|
||||
events: [],
|
||||
};
|
||||
|
||||
mockUseEncounterContext.mockReturnValue(
|
||||
value as ReturnType<typeof useEncounterContext>,
|
||||
);
|
||||
return value;
|
||||
}
|
||||
|
||||
function renderNav(overrides: Partial<Encounter> = {}) {
|
||||
mockContext(overrides);
|
||||
return render(<TurnNavigation />);
|
||||
}
|
||||
|
||||
describe("TurnNavigation", () => {
|
||||
@@ -49,80 +87,39 @@ describe("TurnNavigation", () => {
|
||||
|
||||
it("does not render an em dash between round and name", () => {
|
||||
const { container } = renderNav();
|
||||
expect(container.textContent).not.toContain("—");
|
||||
expect(container.textContent).not.toContain("\u2014");
|
||||
});
|
||||
|
||||
it("round badge and combatant name are siblings in the center area", () => {
|
||||
renderNav();
|
||||
const badge = screen.getByText("R1");
|
||||
const name = screen.getByText("Goblin");
|
||||
expect(badge.parentElement).toBe(name.parentElement);
|
||||
// badge text is inside inner span > outer span, name is a direct child
|
||||
expect(badge.closest(".flex")).toBe(name.parentElement);
|
||||
});
|
||||
|
||||
it("updates the round badge when round changes", () => {
|
||||
const { rerender } = render(
|
||||
<TurnNavigation
|
||||
encounter={{
|
||||
combatants: [{ id: combatantId("1"), name: "Goblin" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 2,
|
||||
}}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
mockContext({ roundNumber: 2 });
|
||||
const { rerender } = render(<TurnNavigation />);
|
||||
expect(screen.getByText("R2")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<TurnNavigation
|
||||
encounter={{
|
||||
combatants: [{ id: combatantId("1"), name: "Goblin" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 3,
|
||||
}}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
mockContext({ roundNumber: 3 });
|
||||
rerender(<TurnNavigation />);
|
||||
expect(screen.getByText("R3")).toBeInTheDocument();
|
||||
expect(screen.queryByText("R2")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the next combatant name when turn advances", () => {
|
||||
const { rerender } = render(
|
||||
<TurnNavigation
|
||||
encounter={{
|
||||
combatants: [
|
||||
{ id: combatantId("1"), name: "Goblin" },
|
||||
{ id: combatantId("2"), name: "Conjurer" },
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
}}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const combatants = [
|
||||
{ id: combatantId("1"), name: "Goblin" },
|
||||
{ id: combatantId("2"), name: "Conjurer" },
|
||||
];
|
||||
mockContext({ combatants, activeIndex: 0 });
|
||||
const { rerender } = render(<TurnNavigation />);
|
||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<TurnNavigation
|
||||
encounter={{
|
||||
combatants: [
|
||||
{ id: combatantId("1"), name: "Goblin" },
|
||||
{ id: combatantId("2"), name: "Conjurer" },
|
||||
],
|
||||
activeIndex: 1,
|
||||
roundNumber: 1,
|
||||
}}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
mockContext({ combatants, activeIndex: 1 });
|
||||
rerender(<TurnNavigation />);
|
||||
expect(screen.getByText("Conjurer")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,17 +19,15 @@ export function AcShield({ value, onClick, className }: AcShieldProps) {
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 28 32"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="var(--color-border)"
|
||||
fillOpacity={0.5}
|
||||
stroke="none"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M14 1.5 L2.5 6.5 L2.5 15 Q2.5 25 14 30.5 Q25.5 25 25.5 15 L25.5 6.5 Z" />
|
||||
</svg>
|
||||
<span className="relative font-medium text-xs leading-none">
|
||||
<span className="relative -mt-0.5 font-medium text-xs leading-none">
|
||||
{value == null ? "\u2014" : String(value)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||
import {
|
||||
Check,
|
||||
Eye,
|
||||
@@ -7,13 +7,27 @@ import {
|
||||
Library,
|
||||
Minus,
|
||||
Plus,
|
||||
Settings,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import React, { type RefObject, useDeferredValue, useState } from "react";
|
||||
import type { SearchResult } from "../hooks/use-bestiary.js";
|
||||
import React, {
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
import { useLongPress } from "../hooks/use-long-press.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { D20Icon } from "./d20-icon.js";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
|
||||
import { RollModeMenu } from "./roll-mode-menu.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
|
||||
@@ -24,25 +38,10 @@ interface QueuedCreature {
|
||||
}
|
||||
|
||||
interface ActionBarProps {
|
||||
onAddCombatant: (
|
||||
name: string,
|
||||
opts?: { initiative?: number; ac?: number; maxHp?: number },
|
||||
) => void;
|
||||
onAddFromBestiary: (result: SearchResult) => void;
|
||||
bestiarySearch: (query: string) => SearchResult[];
|
||||
bestiaryLoaded: boolean;
|
||||
onViewStatBlock?: (result: SearchResult) => void;
|
||||
onBulkImport?: () => void;
|
||||
bulkImportDisabled?: boolean;
|
||||
inputRef?: RefObject<HTMLInputElement | null>;
|
||||
playerCharacters?: readonly PlayerCharacter[];
|
||||
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||
onManagePlayers?: () => void;
|
||||
onRollAllInitiative?: () => void;
|
||||
showRollAllInitiative?: boolean;
|
||||
rollAllInitiativeDisabled?: boolean;
|
||||
onOpenSourceManager?: () => void;
|
||||
autoFocus?: boolean;
|
||||
onManagePlayers?: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
function creatureKey(r: SearchResult): string {
|
||||
@@ -77,7 +76,7 @@ function AddModeSuggestions({
|
||||
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||
}>) {
|
||||
return (
|
||||
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
|
||||
<div className="card-glow absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-lg border border-border bg-card">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1.5 border-border border-b px-3 py-2 text-left text-accent text-sm hover:bg-accent/20"
|
||||
@@ -137,12 +136,14 @@ function AddModeSuggestions({
|
||||
<li key={key}>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${(() => {
|
||||
if (isQueued) return "bg-accent/30 text-foreground";
|
||||
if (i === suggestionIndex)
|
||||
return "bg-accent/20 text-foreground";
|
||||
return "text-foreground hover:bg-hover-neutral-bg";
|
||||
})()}`}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between px-3 py-1.5 text-left text-foreground text-sm",
|
||||
isQueued && "bg-accent/30",
|
||||
!isQueued && i === suggestionIndex && "bg-accent/20",
|
||||
!isQueued &&
|
||||
i !== suggestionIndex &&
|
||||
"hover:bg-hover-neutral-bg",
|
||||
)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onClickSuggestion(result)}
|
||||
onMouseEnter={() => onSetSuggestionIndex(i)}
|
||||
@@ -169,7 +170,7 @@ function AddModeSuggestions({
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-foreground">
|
||||
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground">
|
||||
{queued.count}
|
||||
</span>
|
||||
<button
|
||||
@@ -219,6 +220,7 @@ function buildOverflowItems(opts: {
|
||||
bestiaryLoaded: boolean;
|
||||
onBulkImport?: () => void;
|
||||
bulkImportDisabled?: boolean;
|
||||
onOpenSettings?: () => void;
|
||||
}): OverflowMenuItem[] {
|
||||
const items: OverflowMenuItem[] = [];
|
||||
if (opts.onManagePlayers) {
|
||||
@@ -243,27 +245,59 @@ function buildOverflowItems(opts: {
|
||||
disabled: opts.bulkImportDisabled,
|
||||
});
|
||||
}
|
||||
if (opts.onOpenSettings) {
|
||||
items.push({
|
||||
icon: <Settings className="h-4 w-4" />,
|
||||
label: "Settings",
|
||||
onClick: opts.onOpenSettings,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function ActionBar({
|
||||
onAddCombatant,
|
||||
onAddFromBestiary,
|
||||
bestiarySearch,
|
||||
bestiaryLoaded,
|
||||
onViewStatBlock,
|
||||
onBulkImport,
|
||||
bulkImportDisabled,
|
||||
inputRef,
|
||||
playerCharacters,
|
||||
onAddFromPlayerCharacter,
|
||||
onManagePlayers,
|
||||
onRollAllInitiative,
|
||||
showRollAllInitiative,
|
||||
rollAllInitiativeDisabled,
|
||||
onOpenSourceManager,
|
||||
autoFocus,
|
||||
onManagePlayers,
|
||||
onOpenSettings,
|
||||
}: Readonly<ActionBarProps>) {
|
||||
const {
|
||||
addCombatant,
|
||||
addFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
hasCreatureCombatants,
|
||||
canRollAllInitiative,
|
||||
} = useEncounterContext();
|
||||
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
||||
useBestiaryContext();
|
||||
const { characters: playerCharacters } = usePlayerCharactersContext();
|
||||
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
||||
useSidePanelContext();
|
||||
const { handleRollAllInitiative } = useInitiativeRollsContext();
|
||||
const { state: bulkImportState } = useBulkImportContext();
|
||||
|
||||
const handleAddFromBestiary = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const creatureId = addFromBestiary(result);
|
||||
if (creatureId && panelView.mode === "closed") {
|
||||
showCreature(creatureId);
|
||||
}
|
||||
},
|
||||
[addFromBestiary, panelView.mode, showCreature],
|
||||
);
|
||||
|
||||
const handleViewStatBlock = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const slug = result.name
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
.replaceAll(/(^-|-$)/g, "");
|
||||
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
||||
showCreature(cId);
|
||||
},
|
||||
[showCreature],
|
||||
);
|
||||
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||
@@ -300,7 +334,7 @@ export function ActionBar({
|
||||
const confirmQueued = () => {
|
||||
if (!queued) return;
|
||||
for (let i = 0; i < queued.count; i++) {
|
||||
onAddFromBestiary(queued.result);
|
||||
handleAddFromBestiary(queued.result);
|
||||
}
|
||||
clearInput();
|
||||
};
|
||||
@@ -326,7 +360,7 @@ export function ActionBar({
|
||||
if (init !== undefined) opts.initiative = init;
|
||||
if (ac !== undefined) opts.ac = ac;
|
||||
if (maxHp !== undefined) opts.maxHp = maxHp;
|
||||
onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
|
||||
addCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
@@ -428,34 +462,63 @@ export function ActionBar({
|
||||
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
||||
} else if (e.key === "Enter" && suggestionIndex >= 0) {
|
||||
e.preventDefault();
|
||||
onViewStatBlock?.(suggestions[suggestionIndex]);
|
||||
handleViewStatBlock(suggestions[suggestionIndex]);
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseSelect = (result: SearchResult) => {
|
||||
onViewStatBlock?.(result);
|
||||
handleViewStatBlock(result);
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
};
|
||||
|
||||
const toggleBrowseMode = () => {
|
||||
setBrowseMode((m) => !m);
|
||||
clearInput();
|
||||
setBrowseMode((prev) => {
|
||||
const next = !prev;
|
||||
setSuggestionIndex(-1);
|
||||
setQueued(null);
|
||||
if (next) {
|
||||
handleBrowseSearch(nameInput);
|
||||
} else {
|
||||
handleAddSearch(nameInput);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
clearCustomFields();
|
||||
};
|
||||
|
||||
const [rollAllMenuPos, setRollAllMenuPos] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
const openRollAllMenu = useCallback((x: number, y: number) => {
|
||||
setRollAllMenuPos({ x, y });
|
||||
}, []);
|
||||
|
||||
const rollAllLongPress = useLongPress(
|
||||
useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
if (touch) openRollAllMenu(touch.clientX, touch.clientY);
|
||||
},
|
||||
[openRollAllMenu],
|
||||
),
|
||||
);
|
||||
|
||||
const overflowItems = buildOverflowItems({
|
||||
onManagePlayers,
|
||||
onOpenSourceManager,
|
||||
onOpenSourceManager: showSourceManager,
|
||||
bestiaryLoaded,
|
||||
onBulkImport,
|
||||
bulkImportDisabled,
|
||||
onBulkImport: showBulkImport,
|
||||
bulkImportDisabled: bulkImportState.status === "loading",
|
||||
onOpenSettings,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
||||
<div className="card-glow flex items-center gap-3 border-border border-t bg-card px-4 py-3 sm:rounded-lg sm:border">
|
||||
<form
|
||||
onSubmit={handleAdd}
|
||||
className="relative flex flex-1 items-center gap-2"
|
||||
@@ -474,7 +537,7 @@ export function ActionBar({
|
||||
className="pr-8"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
{bestiaryLoaded && !!onViewStatBlock && (
|
||||
{!!bestiaryLoaded && (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
@@ -482,6 +545,7 @@ export function ActionBar({
|
||||
"absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
|
||||
browseMode && "text-accent",
|
||||
)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={toggleBrowseMode}
|
||||
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
|
||||
aria-label={
|
||||
@@ -496,17 +560,18 @@ export function ActionBar({
|
||||
</button>
|
||||
)}
|
||||
{browseMode && deferredSuggestions.length > 0 && (
|
||||
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
|
||||
<div className="card-glow absolute bottom-full z-50 mb-1 w-full rounded-lg border border-border bg-card">
|
||||
<ul className="max-h-48 overflow-y-auto py-1">
|
||||
{deferredSuggestions.map((result, i) => (
|
||||
<li key={creatureKey(result)}>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between px-3 py-1.5 text-left text-sm",
|
||||
i === suggestionIndex
|
||||
? "bg-accent/20 text-foreground"
|
||||
: "text-foreground hover:bg-hover-neutral-bg"
|
||||
}`}
|
||||
: "text-foreground hover:bg-hover-neutral-bg",
|
||||
)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => handleBrowseSelect(result)}
|
||||
onMouseEnter={() => setSuggestionIndex(i)}
|
||||
@@ -534,7 +599,7 @@ export function ActionBar({
|
||||
onSetSuggestionIndex={setSuggestionIndex}
|
||||
onSetQueued={setQueued}
|
||||
onConfirmQueued={confirmQueued}
|
||||
onAddFromPlayerCharacter={onAddFromPlayerCharacter}
|
||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -570,19 +635,33 @@ export function ActionBar({
|
||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||
<Button type="submit">Add</Button>
|
||||
)}
|
||||
{showRollAllInitiative && !!onRollAllInitiative && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-hover-action"
|
||||
onClick={onRollAllInitiative}
|
||||
disabled={rollAllInitiativeDisabled}
|
||||
title="Roll all initiative"
|
||||
aria-label="Roll all initiative"
|
||||
>
|
||||
<D20Icon className="h-6 w-6" />
|
||||
</Button>
|
||||
{!!hasCreatureCombatants && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-hover-action"
|
||||
onClick={() => handleRollAllInitiative()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openRollAllMenu(e.clientX, e.clientY);
|
||||
}}
|
||||
{...rollAllLongPress}
|
||||
disabled={!canRollAllInitiative}
|
||||
title="Roll all initiative"
|
||||
aria-label="Roll all initiative"
|
||||
>
|
||||
<D20Icon className="h-6 w-6" />
|
||||
</Button>
|
||||
{!!rollAllMenuPos && (
|
||||
<RollModeMenu
|
||||
position={rollAllMenuPos}
|
||||
onSelect={(mode) => handleRollAllInitiative(mode)}
|
||||
onClose={() => setRollAllMenuPos(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
||||
</form>
|
||||
|
||||
@@ -1,35 +1,41 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js";
|
||||
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
const DEFAULT_BASE_URL =
|
||||
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
|
||||
|
||||
interface BulkImportPromptProps {
|
||||
importState: BulkImportState;
|
||||
onStartImport: (baseUrl: string) => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
export function BulkImportPrompt() {
|
||||
const { fetchAndCacheSource, isSourceCached, refreshCache } =
|
||||
useBestiaryContext();
|
||||
const { state: importState, startImport, reset } = useBulkImportContext();
|
||||
const { dismissPanel } = useSidePanelContext();
|
||||
|
||||
export function BulkImportPrompt({
|
||||
importState,
|
||||
onStartImport,
|
||||
onDone,
|
||||
}: Readonly<BulkImportPromptProps>) {
|
||||
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
|
||||
const baseUrlId = useId();
|
||||
const totalSources = getAllSourceCodes().length;
|
||||
|
||||
const handleStart = (url: string) => {
|
||||
startImport(url, fetchAndCacheSource, isSourceCached, refreshCache);
|
||||
};
|
||||
|
||||
const handleDone = () => {
|
||||
dismissPanel();
|
||||
reset();
|
||||
};
|
||||
|
||||
if (importState.status === "complete") {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-green-400 text-sm">
|
||||
All sources loaded
|
||||
</div>
|
||||
<Button onClick={onDone}>Done</Button>
|
||||
<Button onClick={handleDone}>Done</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -41,7 +47,7 @@ export function BulkImportPrompt({
|
||||
Loaded {importState.completed}/{importState.total} sources (
|
||||
{importState.failed} failed)
|
||||
</div>
|
||||
<Button onClick={onDone}>Done</Button>
|
||||
<Button onClick={handleDone}>Done</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -96,7 +102,7 @@ export function BulkImportPrompt({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => onStartImport(baseUrl)} disabled={isDisabled}>
|
||||
<Button onClick={() => handleStart(baseUrl)} disabled={isDisabled}>
|
||||
Load All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
import { Toast } from "./toast.js";
|
||||
|
||||
interface BulkImportToastsProps {
|
||||
state: BulkImportState;
|
||||
visible: boolean;
|
||||
onReset: () => void;
|
||||
}
|
||||
export function BulkImportToasts() {
|
||||
const { state, reset } = useBulkImportContext();
|
||||
const { bulkImportMode, isRightPanelCollapsed } = useSidePanelContext();
|
||||
const visible = !bulkImportMode || isRightPanelCollapsed;
|
||||
|
||||
export function BulkImportToasts({
|
||||
state,
|
||||
visible,
|
||||
onReset,
|
||||
}: Readonly<BulkImportToastsProps>) {
|
||||
if (!visible) return null;
|
||||
|
||||
if (state.status === "loading") {
|
||||
@@ -30,7 +25,7 @@ export function BulkImportToasts({
|
||||
return (
|
||||
<Toast
|
||||
message="All sources loaded"
|
||||
onDismiss={onReset}
|
||||
onDismiss={reset}
|
||||
autoDismissMs={3000}
|
||||
/>
|
||||
);
|
||||
@@ -40,7 +35,7 @@ export function BulkImportToasts({
|
||||
return (
|
||||
<Toast
|
||||
message={`Loaded ${state.completed}/${state.total} sources (${state.failed} failed)`}
|
||||
onDismiss={onReset}
|
||||
onDismiss={reset}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import {
|
||||
type CombatantId,
|
||||
type ConditionId,
|
||||
type CreatureId,
|
||||
deriveHpStatus,
|
||||
type PlayerIcon,
|
||||
type RollMode,
|
||||
} from "@initiative/domain";
|
||||
import { Brain, X } from "lucide-react";
|
||||
import { Brain, Pencil, X } from "lucide-react";
|
||||
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { AcShield } from "./ac-shield";
|
||||
import { ConditionPicker } from "./condition-picker";
|
||||
import { ConditionTags } from "./condition-tags";
|
||||
import { D20Icon } from "./d20-icon";
|
||||
import { HpAdjustPopover } from "./hp-adjust-popover";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
import { Input } from "./ui/input";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
import { useLongPress } from "../hooks/use-long-press.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { AcShield } from "./ac-shield.js";
|
||||
import { ConditionPicker } from "./condition-picker.js";
|
||||
import { ConditionTags } from "./condition-tags.js";
|
||||
import { D20Icon } from "./d20-icon.js";
|
||||
import { HpAdjustPopover } from "./hp-adjust-popover.js";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
|
||||
import { RollModeMenu } from "./roll-mode-menu.js";
|
||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
interface Combatant {
|
||||
readonly id: CombatantId;
|
||||
@@ -22,47 +29,36 @@ interface Combatant {
|
||||
readonly initiative?: number;
|
||||
readonly maxHp?: number;
|
||||
readonly currentHp?: number;
|
||||
readonly tempHp?: number;
|
||||
readonly ac?: number;
|
||||
readonly conditions?: readonly ConditionId[];
|
||||
readonly isConcentrating?: boolean;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
readonly creatureId?: CreatureId;
|
||||
}
|
||||
|
||||
interface CombatantRowProps {
|
||||
combatant: Combatant;
|
||||
isActive: boolean;
|
||||
onRename: (id: CombatantId, newName: string) => void;
|
||||
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
|
||||
onRemove: (id: CombatantId) => void;
|
||||
onSetHp: (id: CombatantId, maxHp: number | undefined) => void;
|
||||
onAdjustHp: (id: CombatantId, delta: number) => void;
|
||||
onSetAc: (id: CombatantId, value: number | undefined) => void;
|
||||
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
|
||||
onToggleConcentration: (id: CombatantId) => void;
|
||||
onShowStatBlock?: () => void;
|
||||
onRollInitiative?: (id: CombatantId) => void;
|
||||
}
|
||||
|
||||
function EditableName({
|
||||
name,
|
||||
combatantId,
|
||||
onRename,
|
||||
onShowStatBlock,
|
||||
color,
|
||||
onToggleStatBlock,
|
||||
}: Readonly<{
|
||||
name: string;
|
||||
combatantId: CombatantId;
|
||||
onRename: (id: CombatantId, newName: string) => void;
|
||||
onShowStatBlock?: () => void;
|
||||
color?: string;
|
||||
onToggleStatBlock?: () => void;
|
||||
}>) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(name);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const longPressTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const longPressTriggeredRef = useRef(false);
|
||||
|
||||
const commit = useCallback(() => {
|
||||
const trimmed = draft.trim();
|
||||
@@ -78,53 +74,13 @@ function EditableName({
|
||||
requestAnimationFrame(() => inputRef.current?.select());
|
||||
}, [name]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(clickTimerRef.current);
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (longPressTriggeredRef.current) {
|
||||
longPressTriggeredRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (clickTimerRef.current) {
|
||||
clearTimeout(clickTimerRef.current);
|
||||
clickTimerRef.current = undefined;
|
||||
startEditing();
|
||||
} else {
|
||||
clickTimerRef.current = setTimeout(() => {
|
||||
clickTimerRef.current = undefined;
|
||||
onShowStatBlock?.();
|
||||
}, 250);
|
||||
}
|
||||
},
|
||||
[startEditing, onShowStatBlock],
|
||||
);
|
||||
|
||||
const handleTouchStart = useCallback(() => {
|
||||
longPressTriggeredRef.current = false;
|
||||
longPressTimerRef.current = setTimeout(() => {
|
||||
longPressTriggeredRef.current = true;
|
||||
startEditing();
|
||||
}, 500);
|
||||
}, [startEditing]);
|
||||
|
||||
const cancelLongPress = useCallback(() => {
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
}, []);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={draft}
|
||||
className="h-7 text-sm"
|
||||
className="h-7 max-w-48 text-sm"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
@@ -136,18 +92,31 @@ function EditableName({
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={cancelLongPress}
|
||||
onTouchCancel={cancelLongPress}
|
||||
onTouchMove={cancelLongPress}
|
||||
className="cursor-text truncate text-left text-foreground text-sm transition-colors hover:text-hover-neutral"
|
||||
style={color ? { color } : undefined}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleStatBlock}
|
||||
disabled={!onToggleStatBlock}
|
||||
className={cn(
|
||||
"truncate text-left text-sm transition-colors",
|
||||
onToggleStatBlock
|
||||
? "cursor-pointer text-foreground hover:text-hover-neutral"
|
||||
: "cursor-default text-foreground",
|
||||
)}
|
||||
style={color ? { color } : undefined}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startEditing}
|
||||
title="Rename"
|
||||
aria-label="Rename"
|
||||
className="inline-flex shrink-0 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"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -203,7 +172,12 @@ function MaxHpDisplay({
|
||||
<button
|
||||
type="button"
|
||||
onClick={startEditing}
|
||||
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"
|
||||
className={cn(
|
||||
"inline-block h-7 min-w-[3ch] text-center leading-7 transition-colors hover:text-hover-neutral",
|
||||
maxHp === undefined
|
||||
? "text-muted-foreground text-sm"
|
||||
: "text-muted-foreground text-xs",
|
||||
)}
|
||||
>
|
||||
{maxHp ?? "Max"}
|
||||
</button>
|
||||
@@ -213,50 +187,47 @@ function MaxHpDisplay({
|
||||
function ClickableHp({
|
||||
currentHp,
|
||||
maxHp,
|
||||
tempHp,
|
||||
onAdjust,
|
||||
dimmed,
|
||||
onSetTempHp,
|
||||
}: Readonly<{
|
||||
currentHp: number | undefined;
|
||||
maxHp: number | undefined;
|
||||
tempHp: number | undefined;
|
||||
onAdjust: (delta: number) => void;
|
||||
dimmed?: boolean;
|
||||
onSetTempHp: (value: number) => void;
|
||||
}>) {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const status = deriveHpStatus(currentHp, maxHp);
|
||||
|
||||
if (maxHp === undefined) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-7 w-[4ch] text-center text-muted-foreground text-sm tabular-nums leading-7",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
role="status"
|
||||
aria-label="No HP set"
|
||||
>
|
||||
--
|
||||
</span>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPopoverOpen(true)}
|
||||
aria-label={`Current HP: ${currentHp}${tempHp ? ` (+${tempHp} temp)` : ""} (${status})`}
|
||||
className={cn(
|
||||
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral",
|
||||
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm leading-7 transition-colors hover:text-hover-neutral",
|
||||
status === "bloodied" && "text-amber-400",
|
||||
status === "unconscious" && "text-red-400",
|
||||
status === "healthy" && "text-foreground",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{currentHp}
|
||||
</button>
|
||||
{!!tempHp && (
|
||||
<span className="font-medium text-cyan-400 text-sm leading-7">
|
||||
+{tempHp}
|
||||
</span>
|
||||
)}
|
||||
{!!popoverOpen && (
|
||||
<HpAdjustPopover
|
||||
onAdjust={onAdjust}
|
||||
onSetTempHp={onSetTempHp}
|
||||
onClose={() => setPopoverOpen(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -326,11 +297,29 @@ function InitiativeDisplay({
|
||||
combatantId: CombatantId;
|
||||
dimmed: boolean;
|
||||
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
|
||||
onRollInitiative?: (id: CombatantId) => void;
|
||||
onRollInitiative?: (id: CombatantId, mode?: RollMode) => void;
|
||||
}>) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(initiative?.toString() ?? "");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [menuPos, setMenuPos] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
const openMenu = useCallback((x: number, y: number) => {
|
||||
setMenuPos({ x, y });
|
||||
}, []);
|
||||
|
||||
const longPress = useLongPress(
|
||||
useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
if (touch) openMenu(touch.clientX, touch.clientY);
|
||||
},
|
||||
[openMenu],
|
||||
),
|
||||
);
|
||||
|
||||
const commit = useCallback(() => {
|
||||
if (draft === "") {
|
||||
@@ -359,7 +348,7 @@ function InitiativeDisplay({
|
||||
value={draft}
|
||||
placeholder="--"
|
||||
className={cn(
|
||||
"h-7 w-[6ch] text-center text-sm tabular-nums",
|
||||
"h-7 w-full text-center text-sm tabular-nums",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
@@ -372,26 +361,40 @@ function InitiativeDisplay({
|
||||
);
|
||||
}
|
||||
|
||||
// Empty + bestiary creature → d20 roll button
|
||||
// Empty + bestiary creature -> d20 roll button
|
||||
if (initiative === undefined && onRollInitiative) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRollInitiative(combatantId)}
|
||||
className={cn(
|
||||
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
|
||||
dimmed && "opacity-50",
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRollInitiative(combatantId)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openMenu(e.clientX, e.clientY);
|
||||
}}
|
||||
{...longPress}
|
||||
className={cn(
|
||||
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
title="Roll initiative"
|
||||
aria-label="Roll initiative"
|
||||
>
|
||||
<D20Icon className="h-7 w-7" />
|
||||
</button>
|
||||
{!!menuPos && (
|
||||
<RollModeMenu
|
||||
position={menuPos}
|
||||
onSelect={(mode) => onRollInitiative(combatantId, mode)}
|
||||
onClose={() => setMenuPos(null)}
|
||||
/>
|
||||
)}
|
||||
title="Roll initiative"
|
||||
aria-label="Roll initiative"
|
||||
>
|
||||
<D20Icon className="h-7 w-7" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Has value → bold number, click to edit
|
||||
// Empty + manual → "--" placeholder, click to edit
|
||||
// Has value -> bold number, click to edit
|
||||
// Empty + manual -> "--" placeholder, click to edit
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -413,9 +416,13 @@ function rowBorderClass(
|
||||
isActive: boolean,
|
||||
isConcentrating: boolean | undefined,
|
||||
): string {
|
||||
if (isActive) return "border-l-2 border-l-accent bg-accent/10";
|
||||
if (isConcentrating) return "border-l-2 border-l-purple-400";
|
||||
return "border-l-2 border-l-transparent";
|
||||
if (isActive && isConcentrating)
|
||||
return "border border-l-2 border-active-row-border border-l-purple-400 bg-active-row-bg card-glow";
|
||||
if (isActive)
|
||||
return "border border-l-2 border-active-row-border bg-active-row-bg card-glow";
|
||||
if (isConcentrating)
|
||||
return "border border-l-2 border-transparent border-l-purple-400";
|
||||
return "border border-l-2 border-transparent";
|
||||
}
|
||||
|
||||
function concentrationIconClass(
|
||||
@@ -427,56 +434,71 @@ function concentrationIconClass(
|
||||
return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400";
|
||||
}
|
||||
|
||||
function activateOnKeyDown(
|
||||
handler: () => void,
|
||||
): (e: { key: string; preventDefault: () => void }) => void {
|
||||
return (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handler();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function CombatantRow({
|
||||
ref,
|
||||
combatant,
|
||||
isActive,
|
||||
onRename,
|
||||
onSetInitiative,
|
||||
onRemove,
|
||||
onSetHp,
|
||||
onAdjustHp,
|
||||
onSetAc,
|
||||
onToggleCondition,
|
||||
onToggleConcentration,
|
||||
onShowStatBlock,
|
||||
onRollInitiative,
|
||||
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
|
||||
const {
|
||||
editCombatant,
|
||||
setInitiative,
|
||||
removeCombatant,
|
||||
setHp,
|
||||
adjustHp,
|
||||
setTempHp,
|
||||
setAc,
|
||||
toggleCondition,
|
||||
toggleConcentration,
|
||||
} = useEncounterContext();
|
||||
const { selectedCreatureId, showCreature, toggleCollapse } =
|
||||
useSidePanelContext();
|
||||
const { handleRollInitiative } = useInitiativeRollsContext();
|
||||
|
||||
// Derive what was previously conditional props
|
||||
const isStatBlockOpen = combatant.creatureId === selectedCreatureId;
|
||||
const { creatureId } = combatant;
|
||||
const hasStatBlock = !!creatureId;
|
||||
const onToggleStatBlock = hasStatBlock
|
||||
? () => {
|
||||
if (isStatBlockOpen) {
|
||||
toggleCollapse();
|
||||
} else {
|
||||
showCreature(creatureId);
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
const onRollInitiative = combatant.creatureId
|
||||
? handleRollInitiative
|
||||
: undefined;
|
||||
|
||||
const { id, name, initiative, maxHp, currentHp } = combatant;
|
||||
const status = deriveHpStatus(currentHp, maxHp);
|
||||
const dimmed = status === "unconscious";
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const conditionAnchorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const prevHpRef = useRef(currentHp);
|
||||
const prevTempHpRef = useRef(combatant.tempHp);
|
||||
const [isPulsing, setIsPulsing] = useState(false);
|
||||
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const prevHp = prevHpRef.current;
|
||||
const prevTempHp = prevTempHpRef.current;
|
||||
prevHpRef.current = currentHp;
|
||||
prevTempHpRef.current = combatant.tempHp;
|
||||
|
||||
if (
|
||||
prevHp !== undefined &&
|
||||
currentHp !== undefined &&
|
||||
currentHp < prevHp &&
|
||||
combatant.isConcentrating
|
||||
) {
|
||||
const realHpDropped =
|
||||
prevHp !== undefined && currentHp !== undefined && currentHp < prevHp;
|
||||
const tempHpDropped =
|
||||
prevTempHp !== undefined && (combatant.tempHp ?? 0) < prevTempHp;
|
||||
|
||||
if ((realHpDropped || tempHpDropped) && combatant.isConcentrating) {
|
||||
setIsPulsing(true);
|
||||
clearTimeout(pulseTimerRef.current);
|
||||
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
|
||||
}
|
||||
}, [currentHp, combatant.isConcentrating]);
|
||||
}, [currentHp, combatant.tempHp, combatant.isConcentrating]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!combatant.isConcentrating) {
|
||||
@@ -490,31 +512,19 @@ export function CombatantRow({
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
/* biome-ignore lint/a11y/noStaticElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
|
||||
/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
|
||||
<div
|
||||
ref={ref}
|
||||
role={onShowStatBlock ? "button" : undefined}
|
||||
tabIndex={onShowStatBlock ? 0 : undefined}
|
||||
className={cn(
|
||||
"group rounded-md pr-3 transition-colors",
|
||||
"group rounded-lg pr-3 transition-colors",
|
||||
rowBorderClass(isActive, combatant.isConcentrating),
|
||||
isPulsing && "animate-concentration-pulse",
|
||||
onShowStatBlock && "cursor-pointer",
|
||||
)}
|
||||
onClick={onShowStatBlock}
|
||||
onKeyDown={
|
||||
onShowStatBlock ? activateOnKeyDown(onShowStatBlock) : undefined
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-[2rem_3rem_1fr_auto_auto_2rem] items-center gap-3 py-2">
|
||||
<div className="grid grid-cols-[2rem_3rem_auto_1fr_auto_2rem] items-center gap-1.5 py-2 sm:grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem] sm:gap-3">
|
||||
{/* Concentration */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleConcentration(id);
|
||||
}}
|
||||
onClick={() => toggleConcentration(id)}
|
||||
title="Concentrating"
|
||||
aria-label="Toggle concentration"
|
||||
className={cn(
|
||||
@@ -526,21 +536,21 @@ export function CombatantRow({
|
||||
</button>
|
||||
|
||||
{/* Initiative */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
||||
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */}
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="rounded-md bg-muted/30 px-1">
|
||||
<InitiativeDisplay
|
||||
initiative={initiative}
|
||||
combatantId={id}
|
||||
dimmed={dimmed}
|
||||
onSetInitiative={onSetInitiative}
|
||||
onSetInitiative={setInitiative}
|
||||
onRollInitiative={onRollInitiative}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AC */}
|
||||
<div className={cn(dimmed && "opacity-50")}>
|
||||
<AcDisplay ac={combatant.ac} onCommit={(v) => setAc(id, v)} />
|
||||
</div>
|
||||
|
||||
{/* Name + Conditions */}
|
||||
<div
|
||||
className={cn(
|
||||
@@ -558,7 +568,7 @@ export function CombatantRow({
|
||||
];
|
||||
return PcIcon ? (
|
||||
<PcIcon
|
||||
size={14}
|
||||
size={16}
|
||||
style={{ color: iconColor }}
|
||||
className="shrink-0"
|
||||
/>
|
||||
@@ -567,69 +577,55 @@ export function CombatantRow({
|
||||
<EditableName
|
||||
name={name}
|
||||
combatantId={id}
|
||||
onRename={onRename}
|
||||
onShowStatBlock={onShowStatBlock}
|
||||
onRename={editCombatant}
|
||||
color={pcColor}
|
||||
onToggleStatBlock={onToggleStatBlock}
|
||||
/>
|
||||
<ConditionTags
|
||||
conditions={combatant.conditions}
|
||||
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
|
||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||
/>
|
||||
<div ref={conditionAnchorRef}>
|
||||
<ConditionTags
|
||||
conditions={combatant.conditions}
|
||||
onRemove={(conditionId) => toggleCondition(id, conditionId)}
|
||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
{!!pickerOpen && (
|
||||
<ConditionPicker
|
||||
anchorRef={conditionAnchorRef}
|
||||
activeConditions={combatant.conditions}
|
||||
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
|
||||
onToggle={(conditionId) => toggleCondition(id, conditionId)}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AC */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
||||
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */}
|
||||
<div
|
||||
className={cn(dimmed && "opacity-50")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
|
||||
</div>
|
||||
|
||||
{/* HP */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
||||
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */}
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"flex items-center rounded-md tabular-nums",
|
||||
maxHp === undefined
|
||||
? ""
|
||||
: "gap-0.5 border border-border/50 bg-muted/30 px-1.5",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<ClickableHp
|
||||
currentHp={currentHp}
|
||||
maxHp={maxHp}
|
||||
onAdjust={(delta) => onAdjustHp(id, delta)}
|
||||
dimmed={dimmed}
|
||||
tempHp={combatant.tempHp}
|
||||
onAdjust={(delta) => adjustHp(id, delta)}
|
||||
onSetTempHp={(value) => setTempHp(id, value)}
|
||||
/>
|
||||
{maxHp !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground text-sm tabular-nums",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
>
|
||||
/
|
||||
</span>
|
||||
<span className="text-muted-foreground/50 text-xs">/</span>
|
||||
)}
|
||||
<div className={cn(dimmed && "opacity-50")}>
|
||||
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
|
||||
</div>
|
||||
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => setHp(id, v)} />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<ConfirmButton
|
||||
icon={<X size={16} />}
|
||||
label="Remove combatant"
|
||||
onConfirm={() => onRemove(id)}
|
||||
onConfirm={() => removeCombatant(id)}
|
||||
className="pointer-events-none pointer-coarse:pointer-events-auto h-7 w-7 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-opacity focus:pointer-events-auto focus:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
|
||||
import {
|
||||
CONDITION_DEFINITIONS,
|
||||
type ConditionId,
|
||||
getConditionDescription,
|
||||
} from "@initiative/domain";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowDown,
|
||||
@@ -18,7 +22,10 @@ import {
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Tooltip } from "./ui/tooltip.js";
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
EyeOff,
|
||||
@@ -52,34 +59,45 @@ const COLOR_CLASSES: Record<string, string> = {
|
||||
};
|
||||
|
||||
interface ConditionPickerProps {
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
activeConditions: readonly ConditionId[] | undefined;
|
||||
onToggle: (conditionId: ConditionId) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConditionPicker({
|
||||
anchorRef,
|
||||
activeConditions,
|
||||
onToggle,
|
||||
onClose,
|
||||
}: Readonly<ConditionPickerProps>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
|
||||
const [pos, setPos] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
maxHeight: number;
|
||||
} | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const anchor = anchorRef.current;
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.top;
|
||||
const spaceAbove = rect.bottom;
|
||||
const shouldFlip =
|
||||
rect.bottom > window.innerHeight && spaceAbove > spaceBelow;
|
||||
setFlipped(shouldFlip);
|
||||
const available = shouldFlip ? spaceAbove : spaceBelow;
|
||||
if (rect.height > available) {
|
||||
setMaxHeight(available - 16);
|
||||
}
|
||||
}, []);
|
||||
if (!anchor || !el) return;
|
||||
|
||||
const anchorRect = anchor.getBoundingClientRect();
|
||||
const menuHeight = el.scrollHeight;
|
||||
const pad = 8;
|
||||
|
||||
const spaceBelow = window.innerHeight - anchorRect.bottom - pad;
|
||||
const spaceAbove = anchorRect.top - pad;
|
||||
const openBelow = spaceBelow >= menuHeight || spaceBelow >= spaceAbove;
|
||||
|
||||
const top = openBelow
|
||||
? anchorRect.bottom + 4
|
||||
: Math.max(pad, anchorRect.top - Math.min(menuHeight, spaceAbove) - 4);
|
||||
const maxHeight = openBelow ? spaceBelow : Math.min(menuHeight, spaceAbove);
|
||||
|
||||
setPos({ top, left: anchorRect.left, maxHeight });
|
||||
}, [anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
@@ -91,16 +109,18 @@ export function ConditionPicker({
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [onClose]);
|
||||
|
||||
const { edition } = useRulesEditionContext();
|
||||
const active = new Set(activeConditions ?? []);
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute left-0 z-10 w-fit overflow-y-auto rounded-md border border-border bg-background p-1 shadow-lg",
|
||||
flipped ? "bottom-full mb-1" : "top-full mt-1",
|
||||
)}
|
||||
style={maxHeight ? { maxHeight } : undefined}
|
||||
className="card-glow fixed z-50 w-fit overflow-y-auto rounded-lg border border-border bg-background p-1"
|
||||
style={
|
||||
pos
|
||||
? { top: pos.top, left: pos.left, maxHeight: pos.maxHeight }
|
||||
: { visibility: "hidden" as const }
|
||||
}
|
||||
>
|
||||
{CONDITION_DEFINITIONS.map((def) => {
|
||||
const Icon = ICON_MAP[def.iconName];
|
||||
@@ -108,27 +128,35 @@ export function ConditionPicker({
|
||||
const isActive = active.has(def.id);
|
||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
return (
|
||||
<button
|
||||
<Tooltip
|
||||
key={def.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
|
||||
isActive && "bg-card/50",
|
||||
)}
|
||||
onClick={() => onToggle(def.id)}
|
||||
content={getConditionDescription(def, edition)}
|
||||
className="block"
|
||||
>
|
||||
<Icon
|
||||
size={14}
|
||||
className={isActive ? colorClass : "text-muted-foreground"}
|
||||
/>
|
||||
<span
|
||||
className={isActive ? "text-foreground" : "text-muted-foreground"}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
|
||||
isActive && "bg-card/50",
|
||||
)}
|
||||
onClick={() => onToggle(def.id)}
|
||||
>
|
||||
{def.label}
|
||||
</span>
|
||||
</button>
|
||||
<Icon
|
||||
size={14}
|
||||
className={isActive ? colorClass : "text-muted-foreground"}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
isActive ? "text-foreground" : "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{def.label}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
|
||||
import {
|
||||
CONDITION_DEFINITIONS,
|
||||
type ConditionId,
|
||||
getConditionDescription,
|
||||
} from "@initiative/domain";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowDown,
|
||||
@@ -18,6 +22,9 @@ import {
|
||||
Sparkles,
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { Tooltip } from "./ui/tooltip.js";
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
EyeOff,
|
||||
@@ -61,6 +68,7 @@ export function ConditionTags({
|
||||
onRemove,
|
||||
onOpenPicker,
|
||||
}: Readonly<ConditionTagsProps>) {
|
||||
const { edition } = useRulesEditionContext();
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-0.5">
|
||||
{conditions?.map((condId) => {
|
||||
@@ -70,19 +78,25 @@ export function ConditionTags({
|
||||
if (!Icon) return null;
|
||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
return (
|
||||
<button
|
||||
<Tooltip
|
||||
key={condId}
|
||||
type="button"
|
||||
title={def.label}
|
||||
aria-label={`Remove ${def.label}`}
|
||||
className={`inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg ${colorClass}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(condId);
|
||||
}}
|
||||
content={`${def.label}:\n${getConditionDescription(def, edition)}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${def.label}`}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
||||
colorClass,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(condId);
|
||||
}}
|
||||
>
|
||||
<Icon size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ColorPalette } from "./color-palette";
|
||||
import { IconGrid } from "./icon-grid";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -25,6 +25,7 @@ export function CreatePlayerModal({
|
||||
onSave,
|
||||
playerCharacter,
|
||||
}: Readonly<CreatePlayerModalProps>) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [ac, setAc] = useState("10");
|
||||
const [maxHp, setMaxHp] = useState("10");
|
||||
@@ -54,15 +55,32 @@ export function CreatePlayerModal({
|
||||
}, [open, playerCharacter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) {
|
||||
dialog.showModal();
|
||||
} else if (!open && dialog.open) {
|
||||
dialog.close();
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
function handleCancel(e: Event) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === dialog) onClose();
|
||||
}
|
||||
dialog.addEventListener("cancel", handleCancel);
|
||||
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", handleCancel);
|
||||
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
@@ -86,106 +104,89 @@ export function CreatePlayerModal({
|
||||
};
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
|
||||
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: backdrop click to close
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onMouseDown={onClose}
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
||||
>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
|
||||
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: prevent close when clicking modal content */}
|
||||
<div
|
||||
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">
|
||||
{isEdit ? "Edit Player" : "Create Player"}
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">
|
||||
{isEdit ? "Edit Player" : "Create Player"}
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<span className="mb-1 block text-muted-foreground text-sm">Name</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
placeholder="Character name"
|
||||
aria-label="Name"
|
||||
autoFocus
|
||||
/>
|
||||
{!!error && <p className="mt-1 text-destructive text-sm">{error}</p>}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<span className="mb-1 block text-muted-foreground text-sm">AC</span>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={ac}
|
||||
onChange={(e) => setAc(e.target.value)}
|
||||
placeholder="AC"
|
||||
aria-label="AC"
|
||||
className="text-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="mb-1 block text-muted-foreground text-sm">
|
||||
Name
|
||||
Max HP
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
placeholder="Character name"
|
||||
aria-label="Name"
|
||||
autoFocus
|
||||
inputMode="numeric"
|
||||
value={maxHp}
|
||||
onChange={(e) => setMaxHp(e.target.value)}
|
||||
placeholder="Max HP"
|
||||
aria-label="Max HP"
|
||||
className="text-center"
|
||||
/>
|
||||
{!!error && (
|
||||
<p className="mt-1 text-destructive text-sm">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<span className="mb-1 block text-muted-foreground text-sm">
|
||||
AC
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={ac}
|
||||
onChange={(e) => setAc(e.target.value)}
|
||||
placeholder="AC"
|
||||
aria-label="AC"
|
||||
className="text-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="mb-1 block text-muted-foreground text-sm">
|
||||
Max HP
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={maxHp}
|
||||
onChange={(e) => setMaxHp(e.target.value)}
|
||||
placeholder="Max HP"
|
||||
aria-label="Max HP"
|
||||
className="text-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="mb-2 block text-muted-foreground text-sm">
|
||||
Color
|
||||
</span>
|
||||
<ColorPalette value={color} onChange={setColor} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-2 block text-muted-foreground text-sm">
|
||||
Color
|
||||
</span>
|
||||
<ColorPalette value={color} onChange={setColor} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="mb-2 block text-muted-foreground text-sm">Icon</span>
|
||||
<IconGrid value={icon} onChange={setIcon} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-2 block text-muted-foreground text-sm">
|
||||
Icon
|
||||
</span>
|
||||
<IconGrid value={icon} onChange={setIcon} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Heart, Sword } from "lucide-react";
|
||||
import { Heart, ShieldPlus, Sword } from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -12,10 +12,15 @@ const DIGITS_ONLY_REGEX = /^\d+$/;
|
||||
|
||||
interface HpAdjustPopoverProps {
|
||||
readonly onAdjust: (delta: number) => void;
|
||||
readonly onSetTempHp: (value: number) => void;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
||||
export function HpAdjustPopover({
|
||||
onAdjust,
|
||||
onSetTempHp,
|
||||
onClose,
|
||||
}: HpAdjustPopoverProps) {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -87,7 +92,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="fixed z-10 rounded-md border border-border bg-background p-2 shadow-lg"
|
||||
className="card-glow fixed z-10 rounded-lg border border-border bg-background p-2"
|
||||
style={
|
||||
pos
|
||||
? { top: pos.top, left: pos.left }
|
||||
@@ -113,7 +118,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isValid}
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-red-400 transition-colors hover:bg-red-950 hover:text-red-300 disabled:pointer-events-none disabled:opacity-50"
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-red-400 transition-colors hover:bg-hp-damage-hover-bg hover:text-red-300 disabled:pointer-events-none disabled:opacity-50"
|
||||
onClick={() => applyDelta(-1)}
|
||||
title="Apply damage"
|
||||
aria-label="Apply damage"
|
||||
@@ -123,13 +128,28 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isValid}
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-emerald-400 transition-colors hover:bg-emerald-950 hover:text-emerald-300 disabled:pointer-events-none disabled:opacity-50"
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-emerald-400 transition-colors hover:bg-hp-heal-hover-bg hover:text-emerald-300 disabled:pointer-events-none disabled:opacity-50"
|
||||
onClick={() => applyDelta(1)}
|
||||
title="Apply healing"
|
||||
aria-label="Apply healing"
|
||||
>
|
||||
<Heart size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isValid}
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-cyan-400 transition-colors hover:bg-cyan-400/10 hover:text-cyan-300 disabled:pointer-events-none disabled:opacity-50"
|
||||
onClick={() => {
|
||||
if (isValid && parsedValue) {
|
||||
onSetTempHp(parsedValue);
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title="Set temp HP"
|
||||
aria-label="Set temp HP"
|
||||
>
|
||||
<ShieldPlus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import { type RefObject, useImperativeHandle, useState } from "react";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
import { CreatePlayerModal } from "./create-player-modal.js";
|
||||
import { PlayerManagement } from "./player-management.js";
|
||||
|
||||
@@ -7,37 +8,14 @@ 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 { characters, createCharacter, editCharacter, deleteCharacter } =
|
||||
usePlayerCharactersContext();
|
||||
|
||||
const [managementOpen, setManagementOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editingPlayer, setEditingPlayer] = useState<
|
||||
@@ -59,7 +37,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||
}}
|
||||
onSave={(name, ac, maxHp, color, icon) => {
|
||||
if (editingPlayer) {
|
||||
onEditCharacter(editingPlayer.id, {
|
||||
editCharacter(editingPlayer.id, {
|
||||
name,
|
||||
ac,
|
||||
maxHp,
|
||||
@@ -67,7 +45,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||
icon: icon ?? null,
|
||||
});
|
||||
} else {
|
||||
onCreateCharacter(name, ac, maxHp, color, icon);
|
||||
createCharacter(name, ac, maxHp, color, icon);
|
||||
}
|
||||
}}
|
||||
playerCharacter={editingPlayer}
|
||||
@@ -81,7 +59,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||
setCreateOpen(true);
|
||||
setManagementOpen(false);
|
||||
}}
|
||||
onDelete={(id) => onDeleteCharacter(id)}
|
||||
onDelete={(id) => deleteCharacter(id)}
|
||||
onCreate={() => {
|
||||
setEditingPlayer(undefined);
|
||||
setCreateOpen(true);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||
import { Button } from "./ui/button";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
@@ -22,102 +22,112 @@ export function PlayerManagement({
|
||||
onDelete,
|
||||
onCreate,
|
||||
}: Readonly<PlayerManagementProps>) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
if (!open) return null;
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) {
|
||||
dialog.showModal();
|
||||
} else if (!open && dialog.open) {
|
||||
dialog.close();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
function handleCancel(e: Event) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === dialog) onClose();
|
||||
}
|
||||
dialog.addEventListener("cancel", handleCancel);
|
||||
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", handleCancel);
|
||||
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
|
||||
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: backdrop click to close
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onMouseDown={onClose}
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
||||
>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
|
||||
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: prevent close when clicking modal content */}
|
||||
<div
|
||||
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">
|
||||
Player Characters
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X size={20} />
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">
|
||||
Player Characters
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{characters.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<p className="text-muted-foreground">No player characters yet</p>
|
||||
<Button onClick={onCreate}>
|
||||
<Plus size={16} />
|
||||
Create your first player character
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{characters.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<p className="text-muted-foreground">No player characters yet</p>
|
||||
<Button onClick={onCreate}>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{characters.map((pc) => {
|
||||
const Icon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
|
||||
const color = pc.color ? PLAYER_COLOR_HEX[pc.color] : undefined;
|
||||
return (
|
||||
<div
|
||||
key={pc.id}
|
||||
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-hover-neutral-bg"
|
||||
>
|
||||
{!!Icon && (
|
||||
<Icon size={18} style={{ color }} className="shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate text-foreground text-sm">
|
||||
{pc.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
AC {pc.ac}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
HP {pc.maxHp}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onEdit(pc)}
|
||||
className="text-muted-foreground"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<ConfirmButton
|
||||
icon={<Trash2 size={14} />}
|
||||
label="Delete player character"
|
||||
onConfirm={() => onDelete(pc.id)}
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button onClick={onCreate} variant="ghost">
|
||||
<Plus size={16} />
|
||||
Create your first player character
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{characters.map((pc) => {
|
||||
const Icon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
|
||||
const color = pc.color ? PLAYER_COLOR_HEX[pc.color] : undefined;
|
||||
return (
|
||||
<div
|
||||
key={pc.id}
|
||||
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-hover-neutral-bg"
|
||||
>
|
||||
{!!Icon && (
|
||||
<Icon size={18} style={{ color }} className="shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate text-foreground text-sm">
|
||||
{pc.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
AC {pc.ac}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
HP {pc.maxHp}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onEdit(pc)}
|
||||
className="text-muted-foreground"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<ConfirmButton
|
||||
icon={<Trash2 size={14} />}
|
||||
label="Delete player character"
|
||||
onConfirm={() => onDelete(pc.id)}
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button onClick={onCreate} variant="ghost">
|
||||
<Plus size={16} />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
88
apps/web/src/components/roll-mode-menu.tsx
Normal file
88
apps/web/src/components/roll-mode-menu.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { RollMode } from "@initiative/domain";
|
||||
import { ChevronsDown, ChevronsUp } from "lucide-react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
interface RollModeMenuProps {
|
||||
readonly position: { x: number; y: number };
|
||||
readonly onSelect: (mode: RollMode) => void;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
export function RollModeMenu({
|
||||
position,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: RollModeMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const vw = document.documentElement.clientWidth;
|
||||
const vh = document.documentElement.clientHeight;
|
||||
|
||||
let left = position.x;
|
||||
let top = position.y;
|
||||
|
||||
if (left + rect.width > vw) left = vw - rect.width - 8;
|
||||
if (left < 8) left = 8;
|
||||
if (top + rect.height > vh) top = position.y - rect.height;
|
||||
if (top < 8) top = 8;
|
||||
|
||||
setPos({ top, left });
|
||||
}, [position.x, position.y]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="card-glow fixed z-50 min-w-40 rounded-lg border border-border bg-card py-1"
|
||||
style={
|
||||
pos
|
||||
? { top: pos.top, left: pos.left }
|
||||
: { visibility: "hidden" as const }
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-emerald-400 text-sm hover:bg-hover-neutral-bg"
|
||||
onClick={() => {
|
||||
onSelect("advantage");
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<ChevronsUp className="h-4 w-4" />
|
||||
Advantage
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-red-400 text-sm hover:bg-hover-neutral-bg"
|
||||
onClick={() => {
|
||||
onSelect("disadvantage");
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<ChevronsDown className="h-4 w-4" />
|
||||
Disadvantage
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
apps/web/src/components/settings-modal.tsx
Normal file
129
apps/web/src/components/settings-modal.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { RulesEdition } from "@initiative/domain";
|
||||
import { Monitor, Moon, Sun, X } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { useThemeContext } from "../contexts/theme-context.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const EDITION_OPTIONS: { value: RulesEdition; label: string }[] = [
|
||||
{ value: "5e", label: "5e (2014)" },
|
||||
{ value: "5.5e", label: "5.5e (2024)" },
|
||||
];
|
||||
|
||||
const THEME_OPTIONS: {
|
||||
value: "system" | "light" | "dark";
|
||||
label: string;
|
||||
icon: typeof Sun;
|
||||
}[] = [
|
||||
{ value: "system", label: "System", icon: Monitor },
|
||||
{ value: "light", label: "Light", icon: Sun },
|
||||
{ value: "dark", label: "Dark", icon: Moon },
|
||||
];
|
||||
|
||||
export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const { edition, setEdition } = useRulesEditionContext();
|
||||
const { preference, setPreference } = useThemeContext();
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) dialog.showModal();
|
||||
else if (!open && dialog.open) dialog.close();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
function handleCancel(e: Event) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === dialog) onClose();
|
||||
}
|
||||
dialog.addEventListener("cancel", handleCancel);
|
||||
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", handleCancel);
|
||||
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="card-glow m-auto w-full max-w-sm rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">Settings</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
||||
Conditions
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{EDITION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 rounded-md px-3 py-1.5 text-sm transition-colors",
|
||||
edition === opt.value
|
||||
? "bg-accent text-primary-foreground"
|
||||
: "bg-card text-muted-foreground hover:bg-hover-neutral-bg hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setEdition(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
||||
Theme
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{THEME_OPTIONS.map((opt) => {
|
||||
const Icon = opt.icon;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm transition-colors",
|
||||
preference === opt.value
|
||||
? "bg-accent text-primary-foreground"
|
||||
: "bg-card text-muted-foreground hover:bg-hover-neutral-bg hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setPreference(opt.value)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,24 @@
|
||||
import { Download, Loader2, Upload } from "lucide-react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { getDefaultFetchUrl } from "../adapters/bestiary-index-adapter.js";
|
||||
import {
|
||||
getDefaultFetchUrl,
|
||||
getSourceDisplayName,
|
||||
} from "../adapters/bestiary-index-adapter.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
interface SourceFetchPromptProps {
|
||||
sourceCode: string;
|
||||
sourceDisplayName: string;
|
||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
||||
onSourceLoaded: () => void;
|
||||
onUploadSource: (sourceCode: string, jsonData: unknown) => Promise<void>;
|
||||
}
|
||||
|
||||
export function SourceFetchPrompt({
|
||||
sourceCode,
|
||||
sourceDisplayName,
|
||||
fetchAndCacheSource,
|
||||
onSourceLoaded,
|
||||
onUploadSource,
|
||||
}: Readonly<SourceFetchPromptProps>) {
|
||||
const { fetchAndCacheSource, uploadAndCacheSource } = useBestiaryContext();
|
||||
const sourceDisplayName = getSourceDisplayName(sourceCode);
|
||||
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
|
||||
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
|
||||
const [error, setError] = useState<string>("");
|
||||
@@ -47,7 +47,7 @@ export function SourceFetchPrompt({
|
||||
try {
|
||||
const text = await file.text();
|
||||
const json = JSON.parse(text);
|
||||
await onUploadSource(sourceCode, json);
|
||||
await uploadAndCacheSource(sourceCode, json);
|
||||
onSourceLoaded();
|
||||
} catch (err) {
|
||||
setStatus("error");
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { Database, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useOptimistic, useState } from "react";
|
||||
import { Database, Search, Trash2 } from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useOptimistic,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
|
||||
import * as bestiaryCache from "../adapters/bestiary-cache.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
interface SourceManagerProps {
|
||||
onCacheCleared: () => void;
|
||||
}
|
||||
|
||||
export function SourceManager({
|
||||
onCacheCleared,
|
||||
}: Readonly<SourceManagerProps>) {
|
||||
export function SourceManager() {
|
||||
const { refreshCache } = useBestiaryContext();
|
||||
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [optimisticSources, applyOptimistic] = useOptimistic(
|
||||
sources,
|
||||
(
|
||||
@@ -36,16 +40,25 @@ export function SourceManager({
|
||||
applyOptimistic({ type: "remove", sourceCode });
|
||||
await bestiaryCache.clearSource(sourceCode);
|
||||
await loadSources();
|
||||
onCacheCleared();
|
||||
void refreshCache();
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
applyOptimistic({ type: "clear" });
|
||||
await bestiaryCache.clearAll();
|
||||
await loadSources();
|
||||
onCacheCleared();
|
||||
void refreshCache();
|
||||
};
|
||||
|
||||
const filteredSources = useMemo(() => {
|
||||
const term = filter.toLowerCase();
|
||||
return term
|
||||
? optimisticSources.filter((s) =>
|
||||
s.displayName.toLowerCase().includes(term),
|
||||
)
|
||||
: optimisticSources;
|
||||
}, [optimisticSources, filter]);
|
||||
|
||||
if (optimisticSources.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||
@@ -70,8 +83,17 @@ export function SourceManager({
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Filter sources…"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{optimisticSources.map((source) => (
|
||||
{filteredSources.map((source) => (
|
||||
<li
|
||||
key={source.sourceCode}
|
||||
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Creature, CreatureId } from "@initiative/domain";
|
||||
import type { CreatureId } from "@initiative/domain";
|
||||
import { PanelRightClose, Pin, PinOff } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
|
||||
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
||||
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
||||
import { SourceManager } from "./source-manager.js";
|
||||
@@ -12,28 +13,8 @@ import { StatBlock } from "./stat-block.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
|
||||
interface StatBlockPanelProps {
|
||||
creatureId: CreatureId | null;
|
||||
creature: Creature | null;
|
||||
isSourceCached: (sourceCode: string) => Promise<boolean>;
|
||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
||||
uploadAndCacheSource: (
|
||||
sourceCode: string,
|
||||
jsonData: unknown,
|
||||
) => Promise<void>;
|
||||
refreshCache: () => Promise<void>;
|
||||
panelRole: "browse" | "pinned";
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
onPin: () => void;
|
||||
onUnpin: () => void;
|
||||
showPinButton: boolean;
|
||||
side: "left" | "right";
|
||||
onDismiss: () => void;
|
||||
bulkImportMode?: boolean;
|
||||
bulkImportState?: BulkImportState;
|
||||
onStartBulkImport?: (baseUrl: string) => void;
|
||||
onBulkImportDone?: () => void;
|
||||
sourceManagerMode?: boolean;
|
||||
}
|
||||
|
||||
function extractSourceCode(cId: CreatureId): string {
|
||||
@@ -55,9 +36,10 @@ function CollapsedTab({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapse}
|
||||
className={`flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral ${
|
||||
side === "right" ? "self-start" : "self-end"
|
||||
}`}
|
||||
className={cn(
|
||||
"flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral",
|
||||
side === "right" ? "self-start" : "self-end",
|
||||
)}
|
||||
aria-label="Expand stat block panel"
|
||||
>
|
||||
<span className="writing-vertical-rl font-medium text-sm">
|
||||
@@ -152,7 +134,11 @@ function DesktopPanel({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel ${sideClasses} ${isCollapsed ? collapsedTranslate : "translate-x-0"}`}
|
||||
className={cn(
|
||||
"panel-glow fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel",
|
||||
sideClasses,
|
||||
isCollapsed ? collapsedTranslate : "translate-x-0",
|
||||
)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<CollapsedTab
|
||||
@@ -194,7 +180,10 @@ function MobileDrawer({
|
||||
aria-label="Close stat block"
|
||||
/>
|
||||
<div
|
||||
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"}`}
|
||||
className={cn(
|
||||
"panel-glow absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-border border-l bg-card",
|
||||
!isSwiping && "animate-slide-in-right",
|
||||
)}
|
||||
style={
|
||||
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
|
||||
}
|
||||
@@ -219,27 +208,49 @@ function MobileDrawer({
|
||||
);
|
||||
}
|
||||
|
||||
function usePanelRole(panelRole: "browse" | "pinned") {
|
||||
const sidePanel = useSidePanelContext();
|
||||
const { getCreature } = useBestiaryContext();
|
||||
|
||||
const creatureId =
|
||||
panelRole === "browse"
|
||||
? sidePanel.selectedCreatureId
|
||||
: sidePanel.pinnedCreatureId;
|
||||
const creature = creatureId ? (getCreature(creatureId) ?? null) : null;
|
||||
|
||||
const isBrowse = panelRole === "browse";
|
||||
return {
|
||||
creatureId,
|
||||
creature,
|
||||
isCollapsed: isBrowse ? sidePanel.isRightPanelCollapsed : false,
|
||||
onToggleCollapse: isBrowse ? sidePanel.toggleCollapse : () => {},
|
||||
onDismiss: isBrowse ? sidePanel.dismissPanel : () => {},
|
||||
onPin: isBrowse ? sidePanel.togglePin : () => {},
|
||||
onUnpin: panelRole === "pinned" ? sidePanel.unpin : () => {},
|
||||
showPinButton: isBrowse && sidePanel.isWideDesktop && !!creature,
|
||||
bulkImportMode: isBrowse && sidePanel.bulkImportMode,
|
||||
sourceManagerMode: isBrowse && sidePanel.sourceManagerMode,
|
||||
};
|
||||
}
|
||||
|
||||
export function StatBlockPanel({
|
||||
creatureId,
|
||||
creature,
|
||||
isSourceCached,
|
||||
fetchAndCacheSource,
|
||||
uploadAndCacheSource,
|
||||
refreshCache,
|
||||
panelRole,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onPin,
|
||||
onUnpin,
|
||||
showPinButton,
|
||||
side,
|
||||
onDismiss,
|
||||
bulkImportMode,
|
||||
bulkImportState,
|
||||
onStartBulkImport,
|
||||
onBulkImportDone,
|
||||
sourceManagerMode,
|
||||
}: Readonly<StatBlockPanelProps>) {
|
||||
const { isSourceCached } = useBestiaryContext();
|
||||
const {
|
||||
creatureId,
|
||||
creature,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onDismiss,
|
||||
onPin,
|
||||
onUnpin,
|
||||
showPinButton,
|
||||
bulkImportMode,
|
||||
sourceManagerMode,
|
||||
} = usePanelRole(panelRole);
|
||||
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
() => globalThis.matchMedia("(min-width: 1024px)").matches,
|
||||
);
|
||||
@@ -276,29 +287,17 @@ export function StatBlockPanel({
|
||||
|
||||
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
||||
|
||||
const handleSourceLoaded = async () => {
|
||||
await refreshCache();
|
||||
const handleSourceLoaded = () => {
|
||||
setNeedsFetch(false);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (sourceManagerMode) {
|
||||
return <SourceManager onCacheCleared={refreshCache} />;
|
||||
return <SourceManager />;
|
||||
}
|
||||
|
||||
if (
|
||||
bulkImportMode &&
|
||||
bulkImportState &&
|
||||
onStartBulkImport &&
|
||||
onBulkImportDone
|
||||
) {
|
||||
return (
|
||||
<BulkImportPrompt
|
||||
importState={bulkImportState}
|
||||
onStartImport={onStartBulkImport}
|
||||
onDone={onBulkImportDone}
|
||||
/>
|
||||
);
|
||||
if (bulkImportMode) {
|
||||
return <BulkImportPrompt />;
|
||||
}
|
||||
|
||||
if (checkingCache) {
|
||||
@@ -315,10 +314,7 @@ export function StatBlockPanel({
|
||||
return (
|
||||
<SourceFetchPrompt
|
||||
sourceCode={sourceCode}
|
||||
sourceDisplayName={getSourceDisplayName(sourceCode)}
|
||||
fetchAndCacheSource={fetchAndCacheSource}
|
||||
onSourceLoaded={handleSourceLoaded}
|
||||
onUploadSource={uploadAndCacheSource}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -352,7 +348,7 @@ export function StatBlockPanel({
|
||||
);
|
||||
}
|
||||
|
||||
if (panelRole === "pinned") return null;
|
||||
if (panelRole === "pinned" || isCollapsed) return null;
|
||||
|
||||
return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ function PropertyLine({
|
||||
|
||||
function SectionDivider() {
|
||||
return (
|
||||
<div className="my-2 h-px bg-gradient-to-r from-amber-800/60 via-amber-700/40 to-transparent" />
|
||||
<div className="my-2 h-px bg-gradient-to-r from-stat-divider-from via-stat-divider-via to-transparent" />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
<div className="space-y-1 text-foreground">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="font-bold text-amber-400 text-xl">{creature.name}</h2>
|
||||
<h2 className="font-bold text-stat-heading text-xl">{creature.name}</h2>
|
||||
<p className="text-muted-foreground text-sm italic">
|
||||
{creature.size} {creature.type}, {creature.alignment}
|
||||
</p>
|
||||
@@ -194,7 +194,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
{creature.actions && creature.actions.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="font-bold text-amber-400 text-base">Actions</h3>
|
||||
<h3 className="font-bold text-base text-stat-heading">Actions</h3>
|
||||
<div className="space-y-2">
|
||||
{creature.actions.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
@@ -209,7 +209,9 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
{creature.bonusActions && creature.bonusActions.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="font-bold text-amber-400 text-base">Bonus Actions</h3>
|
||||
<h3 className="font-bold text-base text-stat-heading">
|
||||
Bonus Actions
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{creature.bonusActions.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
@@ -224,7 +226,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
{creature.reactions && creature.reactions.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="font-bold text-amber-400 text-base">Reactions</h3>
|
||||
<h3 className="font-bold text-base text-stat-heading">Reactions</h3>
|
||||
<div className="space-y-2">
|
||||
{creature.reactions.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
@@ -239,7 +241,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
{!!creature.legendaryActions && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="font-bold text-amber-400 text-base">
|
||||
<h3 className="font-bold text-base text-stat-heading">
|
||||
Legendary Actions
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm italic">
|
||||
|
||||
@@ -24,7 +24,7 @@ export function Toast({
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed bottom-4 left-4 z-50">
|
||||
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg">
|
||||
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
|
||||
<span className="text-foreground text-sm">{message}</span>
|
||||
{progress !== undefined && (
|
||||
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
|
||||
|
||||
@@ -1,31 +1,22 @@
|
||||
import type { Encounter } from "@initiative/domain";
|
||||
import { StepBack, StepForward, Trash2 } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||
|
||||
interface TurnNavigationProps {
|
||||
encounter: Encounter;
|
||||
onAdvanceTurn: () => void;
|
||||
onRetreatTurn: () => void;
|
||||
onClearEncounter: () => void;
|
||||
}
|
||||
export function TurnNavigation() {
|
||||
const { encounter, advanceTurn, retreatTurn, clearEncounter } =
|
||||
useEncounterContext();
|
||||
|
||||
export function TurnNavigation({
|
||||
encounter,
|
||||
onAdvanceTurn,
|
||||
onRetreatTurn,
|
||||
onClearEncounter,
|
||||
}: Readonly<TurnNavigationProps>) {
|
||||
const hasCombatants = encounter.combatants.length > 0;
|
||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
||||
<div className="card-glow flex items-center gap-3 border-border border-b bg-card px-4 py-3 sm:rounded-lg sm:border">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRetreatTurn}
|
||||
onClick={retreatTurn}
|
||||
disabled={!hasCombatants || isAtStart}
|
||||
title="Previous turn"
|
||||
aria-label="Previous turn"
|
||||
@@ -34,8 +25,10 @@ export function TurnNavigation({
|
||||
</Button>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-2 text-sm">
|
||||
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
|
||||
R{encounter.roundNumber}
|
||||
<span className="shrink-0 rounded-md bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
|
||||
<span className="-mt-[3px] inline-block">
|
||||
R{encounter.roundNumber}
|
||||
</span>
|
||||
</span>
|
||||
{activeCombatant ? (
|
||||
<span className="truncate font-medium">{activeCombatant.name}</span>
|
||||
@@ -48,14 +41,14 @@ export function TurnNavigation({
|
||||
<ConfirmButton
|
||||
icon={<Trash2 className="h-5 w-5" />}
|
||||
label="Clear encounter"
|
||||
onConfirm={onClearEncounter}
|
||||
onConfirm={clearEncounter}
|
||||
disabled={!hasCombatants}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onAdvanceTurn}
|
||||
onClick={advanceTurn}
|
||||
disabled={!hasCombatants}
|
||||
title="Next turn"
|
||||
aria-label="Next turn"
|
||||
|
||||
@@ -9,8 +9,9 @@ const buttonVariants = cva(
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
outline:
|
||||
"border border-border bg-transparent hover:bg-hover-neutral-bg hover:text-hover-neutral",
|
||||
ghost: "hover:bg-hover-neutral-bg hover:text-hover-neutral",
|
||||
"border border-border bg-background/50 text-foreground hover:bg-hover-neutral-bg hover:text-hover-neutral",
|
||||
ghost:
|
||||
"text-foreground hover:bg-hover-neutral-bg hover:text-hover-neutral",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 px-3 text-xs",
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface OverflowMenuItem {
|
||||
readonly label: string;
|
||||
readonly onClick: () => void;
|
||||
readonly disabled?: boolean;
|
||||
readonly keepOpen?: boolean;
|
||||
}
|
||||
|
||||
interface OverflowMenuProps {
|
||||
@@ -49,7 +50,7 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
|
||||
<EllipsisVertical className="h-5 w-5" />
|
||||
</Button>
|
||||
{!!open && (
|
||||
<div className="absolute right-0 bottom-full z-50 mb-1 min-w-48 rounded-md border border-border bg-card py-1 shadow-lg">
|
||||
<div className="card-glow absolute right-0 bottom-full z-50 mb-1 min-w-48 rounded-lg border border-border bg-card py-1">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
@@ -58,7 +59,7 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
item.onClick();
|
||||
setOpen(false);
|
||||
if (!item.keepOpen) setOpen(false);
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
|
||||
55
apps/web/src/components/ui/tooltip.tsx
Normal file
55
apps/web/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { type ReactNode, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface TooltipProps {
|
||||
content: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Tooltip({
|
||||
content,
|
||||
children,
|
||||
className,
|
||||
}: Readonly<TooltipProps>) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
function show() {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
setPos({
|
||||
top: rect.top - 4,
|
||||
left: rect.left + rect.width / 2,
|
||||
});
|
||||
}
|
||||
|
||||
function hide() {
|
||||
setPos(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={ref}
|
||||
onPointerEnter={show}
|
||||
onPointerLeave={hide}
|
||||
className={className ?? "inline-flex"}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{pos !== null &&
|
||||
createPortal(
|
||||
<div
|
||||
role="tooltip"
|
||||
className="pointer-events-none fixed z-[60] max-w-64 -translate-x-1/2 -translate-y-full whitespace-pre-line rounded-md border border-border bg-background px-2.5 py-1.5 text-foreground text-xs leading-snug shadow-lg"
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
>
|
||||
{content}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
23
apps/web/src/contexts/bestiary-context.tsx
Normal file
23
apps/web/src/contexts/bestiary-context.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { useBestiary } from "../hooks/use-bestiary.js";
|
||||
|
||||
export type { SearchResult } from "../hooks/use-bestiary.js";
|
||||
|
||||
type BestiaryContextValue = ReturnType<typeof useBestiary>;
|
||||
|
||||
const BestiaryContext = createContext<BestiaryContextValue | null>(null);
|
||||
|
||||
export function BestiaryProvider({ children }: { children: ReactNode }) {
|
||||
const value = useBestiary();
|
||||
return (
|
||||
<BestiaryContext.Provider value={value}>
|
||||
{children}
|
||||
</BestiaryContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBestiaryContext(): BestiaryContextValue {
|
||||
const ctx = useContext(BestiaryContext);
|
||||
if (!ctx) throw new Error("useBestiaryContext requires BestiaryProvider");
|
||||
return ctx;
|
||||
}
|
||||
21
apps/web/src/contexts/bulk-import-context.tsx
Normal file
21
apps/web/src/contexts/bulk-import-context.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { useBulkImport } from "../hooks/use-bulk-import.js";
|
||||
|
||||
type BulkImportContextValue = ReturnType<typeof useBulkImport>;
|
||||
|
||||
const BulkImportContext = createContext<BulkImportContextValue | null>(null);
|
||||
|
||||
export function BulkImportProvider({ children }: { children: ReactNode }) {
|
||||
const value = useBulkImport();
|
||||
return (
|
||||
<BulkImportContext.Provider value={value}>
|
||||
{children}
|
||||
</BulkImportContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBulkImportContext(): BulkImportContextValue {
|
||||
const ctx = useContext(BulkImportContext);
|
||||
if (!ctx) throw new Error("useBulkImportContext requires BulkImportProvider");
|
||||
return ctx;
|
||||
}
|
||||
21
apps/web/src/contexts/encounter-context.tsx
Normal file
21
apps/web/src/contexts/encounter-context.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { useEncounter } from "../hooks/use-encounter.js";
|
||||
|
||||
type EncounterContextValue = ReturnType<typeof useEncounter>;
|
||||
|
||||
const EncounterContext = createContext<EncounterContextValue | null>(null);
|
||||
|
||||
export function EncounterProvider({ children }: { children: ReactNode }) {
|
||||
const value = useEncounter();
|
||||
return (
|
||||
<EncounterContext.Provider value={value}>
|
||||
{children}
|
||||
</EncounterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useEncounterContext(): EncounterContextValue {
|
||||
const ctx = useContext(EncounterContext);
|
||||
if (!ctx) throw new Error("useEncounterContext requires EncounterProvider");
|
||||
return ctx;
|
||||
}
|
||||
8
apps/web/src/contexts/index.ts
Normal file
8
apps/web/src/contexts/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { BestiaryProvider } from "./bestiary-context.js";
|
||||
export { BulkImportProvider } from "./bulk-import-context.js";
|
||||
export { EncounterProvider } from "./encounter-context.js";
|
||||
export { InitiativeRollsProvider } from "./initiative-rolls-context.js";
|
||||
export { PlayerCharactersProvider } from "./player-characters-context.js";
|
||||
export { RulesEditionProvider } from "./rules-edition-context.js";
|
||||
export { SidePanelProvider } from "./side-panel-context.js";
|
||||
export { ThemeProvider } from "./theme-context.js";
|
||||
25
apps/web/src/contexts/initiative-rolls-context.tsx
Normal file
25
apps/web/src/contexts/initiative-rolls-context.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { useInitiativeRolls } from "../hooks/use-initiative-rolls.js";
|
||||
|
||||
type InitiativeRollsContextValue = ReturnType<typeof useInitiativeRolls>;
|
||||
|
||||
const InitiativeRollsContext =
|
||||
createContext<InitiativeRollsContextValue | null>(null);
|
||||
|
||||
export function InitiativeRollsProvider({ children }: { children: ReactNode }) {
|
||||
const value = useInitiativeRolls();
|
||||
return (
|
||||
<InitiativeRollsContext.Provider value={value}>
|
||||
{children}
|
||||
</InitiativeRollsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useInitiativeRollsContext(): InitiativeRollsContextValue {
|
||||
const ctx = useContext(InitiativeRollsContext);
|
||||
if (!ctx)
|
||||
throw new Error(
|
||||
"useInitiativeRollsContext requires InitiativeRollsProvider",
|
||||
);
|
||||
return ctx;
|
||||
}
|
||||
29
apps/web/src/contexts/player-characters-context.tsx
Normal file
29
apps/web/src/contexts/player-characters-context.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { usePlayerCharacters } from "../hooks/use-player-characters.js";
|
||||
|
||||
type PlayerCharactersContextValue = ReturnType<typeof usePlayerCharacters>;
|
||||
|
||||
const PlayerCharactersContext =
|
||||
createContext<PlayerCharactersContextValue | null>(null);
|
||||
|
||||
export function PlayerCharactersProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const value = usePlayerCharacters();
|
||||
return (
|
||||
<PlayerCharactersContext.Provider value={value}>
|
||||
{children}
|
||||
</PlayerCharactersContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePlayerCharactersContext(): PlayerCharactersContextValue {
|
||||
const ctx = useContext(PlayerCharactersContext);
|
||||
if (!ctx)
|
||||
throw new Error(
|
||||
"usePlayerCharactersContext requires PlayerCharactersProvider",
|
||||
);
|
||||
return ctx;
|
||||
}
|
||||
24
apps/web/src/contexts/rules-edition-context.tsx
Normal file
24
apps/web/src/contexts/rules-edition-context.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { useRulesEdition } from "../hooks/use-rules-edition.js";
|
||||
|
||||
type RulesEditionContextValue = ReturnType<typeof useRulesEdition>;
|
||||
|
||||
const RulesEditionContext = createContext<RulesEditionContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export function RulesEditionProvider({ children }: { children: ReactNode }) {
|
||||
const value = useRulesEdition();
|
||||
return (
|
||||
<RulesEditionContext.Provider value={value}>
|
||||
{children}
|
||||
</RulesEditionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useRulesEditionContext(): RulesEditionContextValue {
|
||||
const ctx = useContext(RulesEditionContext);
|
||||
if (!ctx)
|
||||
throw new Error("useRulesEditionContext requires RulesEditionProvider");
|
||||
return ctx;
|
||||
}
|
||||
21
apps/web/src/contexts/side-panel-context.tsx
Normal file
21
apps/web/src/contexts/side-panel-context.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { useSidePanelState } from "../hooks/use-side-panel-state.js";
|
||||
|
||||
type SidePanelContextValue = ReturnType<typeof useSidePanelState>;
|
||||
|
||||
const SidePanelContext = createContext<SidePanelContextValue | null>(null);
|
||||
|
||||
export function SidePanelProvider({ children }: { children: ReactNode }) {
|
||||
const value = useSidePanelState();
|
||||
return (
|
||||
<SidePanelContext.Provider value={value}>
|
||||
{children}
|
||||
</SidePanelContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSidePanelContext(): SidePanelContextValue {
|
||||
const ctx = useContext(SidePanelContext);
|
||||
if (!ctx) throw new Error("useSidePanelContext requires SidePanelProvider");
|
||||
return ctx;
|
||||
}
|
||||
19
apps/web/src/contexts/theme-context.tsx
Normal file
19
apps/web/src/contexts/theme-context.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { useTheme } from "../hooks/use-theme.js";
|
||||
|
||||
type ThemeContextValue = ReturnType<typeof useTheme>;
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const value = useTheme();
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useThemeContext(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useThemeContext requires ThemeProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -137,7 +137,9 @@ describe("useEncounter", () => {
|
||||
type: "humanoid",
|
||||
};
|
||||
|
||||
act(() => result.current.addFromBestiary(entry));
|
||||
act(() => {
|
||||
result.current.addFromBestiary(entry);
|
||||
});
|
||||
|
||||
expect(result.current.hasCreatureCombatants).toBe(true);
|
||||
expect(result.current.canRollAllInitiative).toBe(true);
|
||||
@@ -158,7 +160,9 @@ describe("useEncounter", () => {
|
||||
type: "humanoid",
|
||||
};
|
||||
|
||||
act(() => result.current.addFromBestiary(entry));
|
||||
act(() => {
|
||||
result.current.addFromBestiary(entry);
|
||||
});
|
||||
|
||||
const combatant = result.current.encounter.combatants[0];
|
||||
expect(combatant.name).toBe("Goblin");
|
||||
@@ -183,8 +187,12 @@ describe("useEncounter", () => {
|
||||
type: "humanoid",
|
||||
};
|
||||
|
||||
act(() => result.current.addFromBestiary(entry));
|
||||
act(() => result.current.addFromBestiary(entry));
|
||||
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");
|
||||
|
||||
38
apps/web/src/hooks/use-action-bar-animation.ts
Normal file
38
apps/web/src/hooks/use-action-bar-animation.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
export function useActionBarAnimation(combatantCount: number) {
|
||||
const wasEmptyRef = useRef(combatantCount === 0);
|
||||
const [settling, setSettling] = useState(false);
|
||||
const [rising, setRising] = useState(false);
|
||||
const [topBarExiting, setTopBarExiting] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const nowEmpty = combatantCount === 0;
|
||||
if (wasEmptyRef.current && !nowEmpty) {
|
||||
setSettling(true);
|
||||
} else if (!wasEmptyRef.current && nowEmpty) {
|
||||
setRising(true);
|
||||
setTopBarExiting(true);
|
||||
}
|
||||
wasEmptyRef.current = nowEmpty;
|
||||
}, [combatantCount]);
|
||||
|
||||
const empty = combatantCount === 0;
|
||||
const risingClass = rising ? "animate-rise-to-center" : "";
|
||||
const settlingClass = settling ? "animate-settle-to-bottom" : "";
|
||||
const exitingClass = topBarExiting
|
||||
? "absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
|
||||
: "";
|
||||
const topBarClass = settling ? "animate-slide-down-in" : exitingClass;
|
||||
const showTopBar = !empty || topBarExiting;
|
||||
|
||||
return {
|
||||
risingClass,
|
||||
settlingClass,
|
||||
topBarClass,
|
||||
showTopBar,
|
||||
onSettleEnd: () => setSettling(false),
|
||||
onRiseEnd: () => setRising(false),
|
||||
onTopBarExitEnd: () => setTopBarExiting(false),
|
||||
};
|
||||
}
|
||||
17
apps/web/src/hooks/use-auto-stat-block.ts
Normal file
17
apps/web/src/hooks/use-auto-stat-block.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
|
||||
export function useAutoStatBlock(): void {
|
||||
const { encounter } = useEncounterContext();
|
||||
const { panelView, updateCreature } = useSidePanelContext();
|
||||
|
||||
const activeCreatureId =
|
||||
encounter.combatants[encounter.activeIndex]?.creatureId;
|
||||
|
||||
useEffect(() => {
|
||||
if (activeCreatureId && panelView.mode === "creature") {
|
||||
updateCreature(activeCreatureId);
|
||||
}
|
||||
}, [activeCreatureId, panelView.mode, updateCreature]);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
|
||||
const BATCH_SIZE = 6;
|
||||
|
||||
export interface BulkImportState {
|
||||
interface BulkImportState {
|
||||
readonly status: "idle" | "loading" | "complete" | "partial-failure";
|
||||
readonly total: number;
|
||||
readonly completed: number;
|
||||
@@ -73,32 +73,39 @@ export function useBulkImport(): BulkImportHook {
|
||||
|
||||
setState((s) => ({ ...s, completed: alreadyCached }));
|
||||
|
||||
const batches: { code: string }[][] = [];
|
||||
for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
|
||||
const batch = uncached.slice(i, i + BATCH_SIZE);
|
||||
// biome-ignore lint/performance/noAwaitInLoops: sequential batching is intentional to avoid overwhelming the server with too many concurrent requests
|
||||
await Promise.allSettled(
|
||||
batch.map(async ({ code }) => {
|
||||
const url = getDefaultFetchUrl(code, baseUrl);
|
||||
try {
|
||||
await fetchAndCacheSource(code, url);
|
||||
countersRef.current.completed++;
|
||||
} catch (err) {
|
||||
countersRef.current.failed++;
|
||||
console.warn(
|
||||
`[bulk-import] FAILED ${code} (${url}):`,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
setState({
|
||||
status: "loading",
|
||||
total,
|
||||
completed: countersRef.current.completed,
|
||||
failed: countersRef.current.failed,
|
||||
});
|
||||
}),
|
||||
);
|
||||
batches.push(uncached.slice(i, i + BATCH_SIZE));
|
||||
}
|
||||
|
||||
await batches.reduce(
|
||||
(chain, batch) =>
|
||||
chain.then(() =>
|
||||
Promise.allSettled(
|
||||
batch.map(async ({ code }) => {
|
||||
const url = getDefaultFetchUrl(code, baseUrl);
|
||||
try {
|
||||
await fetchAndCacheSource(code, url);
|
||||
countersRef.current.completed++;
|
||||
} catch (err) {
|
||||
countersRef.current.failed++;
|
||||
console.warn(
|
||||
`[bulk-import] FAILED ${code} (${url}):`,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
setState({
|
||||
status: "loading",
|
||||
total,
|
||||
completed: countersRef.current.completed,
|
||||
failed: countersRef.current.failed,
|
||||
});
|
||||
}),
|
||||
),
|
||||
),
|
||||
Promise.resolve() as Promise<unknown>,
|
||||
);
|
||||
|
||||
await refreshCache();
|
||||
|
||||
const { completed, failed } = countersRef.current;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
setAcUseCase,
|
||||
setHpUseCase,
|
||||
setInitiativeUseCase,
|
||||
setTempHpUseCase,
|
||||
toggleConcentrationUseCase,
|
||||
toggleConditionUseCase,
|
||||
} from "@initiative/application";
|
||||
@@ -17,6 +18,7 @@ import type {
|
||||
BestiaryIndexEntry,
|
||||
CombatantId,
|
||||
ConditionId,
|
||||
CreatureId,
|
||||
DomainEvent,
|
||||
Encounter,
|
||||
PlayerCharacter,
|
||||
@@ -214,6 +216,19 @@ export function useEncounter() {
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
const setTempHp = useCallback(
|
||||
(id: CombatantId, tempHp: number | undefined) => {
|
||||
const result = setTempHpUseCase(makeStore(), id, tempHp);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
const setAc = useCallback(
|
||||
(id: CombatantId, value: number | undefined) => {
|
||||
const result = setAcUseCase(makeStore(), id, value);
|
||||
@@ -265,7 +280,7 @@ export function useEncounter() {
|
||||
}, [makeStore]);
|
||||
|
||||
const addFromBestiary = useCallback(
|
||||
(entry: BestiaryIndexEntry) => {
|
||||
(entry: BestiaryIndexEntry): CreatureId | null => {
|
||||
const store = makeStore();
|
||||
const existingNames = store.get().combatants.map((c) => c.name);
|
||||
const { newName, renames } = resolveCreatureName(
|
||||
@@ -284,7 +299,7 @@ export function useEncounter() {
|
||||
// Add combatant with resolved name
|
||||
const id = combatantId(`c-${++nextId.current}`);
|
||||
const addResult = addCombatantUseCase(makeStore(), id, newName);
|
||||
if (isDomainError(addResult)) return;
|
||||
if (isDomainError(addResult)) return null;
|
||||
|
||||
// Set HP
|
||||
const hpResult = setHpUseCase(makeStore(), id, entry.hp);
|
||||
@@ -317,6 +332,8 @@ export function useEncounter() {
|
||||
});
|
||||
|
||||
setEvents((prev) => [...prev, ...addResult]);
|
||||
|
||||
return cId;
|
||||
},
|
||||
[makeStore],
|
||||
);
|
||||
@@ -373,6 +390,10 @@ export function useEncounter() {
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
const hasTempHp = encounter.combatants.some(
|
||||
(c) => c.tempHp !== undefined && c.tempHp > 0,
|
||||
);
|
||||
|
||||
const isEmpty = encounter.combatants.length === 0;
|
||||
const hasCreatureCombatants = encounter.combatants.some(
|
||||
(c) => c.creatureId != null,
|
||||
@@ -385,6 +406,7 @@ export function useEncounter() {
|
||||
encounter,
|
||||
events,
|
||||
isEmpty,
|
||||
hasTempHp,
|
||||
hasCreatureCombatants,
|
||||
canRollAllInitiative,
|
||||
advanceTurn,
|
||||
@@ -396,6 +418,7 @@ export function useEncounter() {
|
||||
setInitiative,
|
||||
setHp,
|
||||
adjustHp,
|
||||
setTempHp,
|
||||
setAc,
|
||||
toggleCondition,
|
||||
toggleConcentration,
|
||||
|
||||
75
apps/web/src/hooks/use-initiative-rolls.ts
Normal file
75
apps/web/src/hooks/use-initiative-rolls.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
rollAllInitiativeUseCase,
|
||||
rollInitiativeUseCase,
|
||||
} from "@initiative/application";
|
||||
import {
|
||||
type CombatantId,
|
||||
isDomainError,
|
||||
type RollMode,
|
||||
} from "@initiative/domain";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
|
||||
function rollDice(): number {
|
||||
return Math.floor(Math.random() * 20) + 1;
|
||||
}
|
||||
|
||||
export function useInitiativeRolls() {
|
||||
const { encounter, makeStore } = useEncounterContext();
|
||||
const { getCreature } = useBestiaryContext();
|
||||
const { showCreature } = useSidePanelContext();
|
||||
|
||||
const [rollSkippedCount, setRollSkippedCount] = useState(0);
|
||||
const [rollSingleSkipped, setRollSingleSkipped] = useState(false);
|
||||
|
||||
const handleRollInitiative = useCallback(
|
||||
(id: CombatantId, mode: RollMode = "normal") => {
|
||||
const diceRolls: [number, ...number[]] =
|
||||
mode === "normal" ? [rollDice()] : [rollDice(), rollDice()];
|
||||
const result = rollInitiativeUseCase(
|
||||
makeStore(),
|
||||
id,
|
||||
diceRolls,
|
||||
getCreature,
|
||||
mode,
|
||||
);
|
||||
if (isDomainError(result)) {
|
||||
setRollSingleSkipped(true);
|
||||
const combatant = encounter.combatants.find((c) => c.id === id);
|
||||
if (combatant?.creatureId) {
|
||||
showCreature(combatant.creatureId);
|
||||
}
|
||||
}
|
||||
},
|
||||
[makeStore, getCreature, encounter.combatants, showCreature],
|
||||
);
|
||||
|
||||
const handleRollAllInitiative = useCallback(
|
||||
(mode: RollMode = "normal") => {
|
||||
const result = rollAllInitiativeUseCase(
|
||||
makeStore(),
|
||||
rollDice,
|
||||
getCreature,
|
||||
mode,
|
||||
);
|
||||
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
||||
setRollSkippedCount(result.skippedNoSource);
|
||||
}
|
||||
},
|
||||
[makeStore, getCreature],
|
||||
);
|
||||
|
||||
return {
|
||||
rollSkippedCount,
|
||||
rollSingleSkipped,
|
||||
dismissRollSkipped: useCallback(() => setRollSkippedCount(0), []),
|
||||
dismissRollSingleSkipped: useCallback(
|
||||
() => setRollSingleSkipped(false),
|
||||
[],
|
||||
),
|
||||
handleRollInitiative,
|
||||
handleRollAllInitiative,
|
||||
} as const;
|
||||
}
|
||||
32
apps/web/src/hooks/use-long-press.ts
Normal file
32
apps/web/src/hooks/use-long-press.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
const LONG_PRESS_MS = 500;
|
||||
|
||||
export function useLongPress(onLongPress: (e: React.TouchEvent) => void) {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const firedRef = useRef(false);
|
||||
|
||||
const onTouchStart = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
firedRef.current = false;
|
||||
timerRef.current = setTimeout(() => {
|
||||
firedRef.current = true;
|
||||
onLongPress(e);
|
||||
}, LONG_PRESS_MS);
|
||||
},
|
||||
[onLongPress],
|
||||
);
|
||||
|
||||
const onTouchEnd = useCallback((e: React.TouchEvent) => {
|
||||
clearTimeout(timerRef.current);
|
||||
if (firedRef.current) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onTouchMove = useCallback(() => {
|
||||
clearTimeout(timerRef.current);
|
||||
}, []);
|
||||
|
||||
return { onTouchStart, onTouchEnd, onTouchMove };
|
||||
}
|
||||
52
apps/web/src/hooks/use-rules-edition.ts
Normal file
52
apps/web/src/hooks/use-rules-edition.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { RulesEdition } from "@initiative/domain";
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
const STORAGE_KEY = "initiative:rules-edition";
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let currentEdition: RulesEdition = loadEdition();
|
||||
|
||||
function loadEdition(): RulesEdition {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === "5e" || raw === "5.5e") return raw;
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
return "5.5e";
|
||||
}
|
||||
|
||||
function saveEdition(edition: RulesEdition): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, edition);
|
||||
} catch {
|
||||
// quota exceeded or storage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function notifyAll(): void {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(callback: () => void): () => void {
|
||||
listeners.add(callback);
|
||||
return () => listeners.delete(callback);
|
||||
}
|
||||
|
||||
function getSnapshot(): RulesEdition {
|
||||
return currentEdition;
|
||||
}
|
||||
|
||||
export function useRulesEdition() {
|
||||
const edition = useSyncExternalStore(subscribe, getSnapshot);
|
||||
|
||||
const setEdition = useCallback((next: RulesEdition) => {
|
||||
currentEdition = next;
|
||||
saveEdition(next);
|
||||
notifyAll();
|
||||
}, []);
|
||||
|
||||
return { edition, setEdition } as const;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ interface SidePanelState {
|
||||
|
||||
interface SidePanelActions {
|
||||
showCreature: (creatureId: CreatureId) => void;
|
||||
updateCreature: (creatureId: CreatureId) => void;
|
||||
showBulkImport: () => void;
|
||||
showSourceManager: () => void;
|
||||
dismissPanel: () => void;
|
||||
@@ -52,6 +53,10 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
|
||||
setIsRightPanelCollapsed(false);
|
||||
}, []);
|
||||
|
||||
const updateCreature = useCallback((creatureId: CreatureId) => {
|
||||
setPanelView({ mode: "creature", creatureId });
|
||||
}, []);
|
||||
|
||||
const showBulkImport = useCallback(() => {
|
||||
setPanelView({ mode: "bulk-import" });
|
||||
setIsRightPanelCollapsed(false);
|
||||
@@ -91,6 +96,7 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
|
||||
pinnedCreatureId,
|
||||
isWideDesktop,
|
||||
showCreature,
|
||||
updateCreature,
|
||||
showBulkImport,
|
||||
showSourceManager,
|
||||
dismissPanel,
|
||||
|
||||
90
apps/web/src/hooks/use-theme.ts
Normal file
90
apps/web/src/hooks/use-theme.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useCallback, useEffect, useSyncExternalStore } from "react";
|
||||
|
||||
type ThemePreference = "system" | "light" | "dark";
|
||||
type ResolvedTheme = "light" | "dark";
|
||||
|
||||
const STORAGE_KEY = "initiative:theme";
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let currentPreference: ThemePreference = loadPreference();
|
||||
|
||||
function loadPreference(): ThemePreference {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === "light" || raw === "dark" || raw === "system") return raw;
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
return "system";
|
||||
}
|
||||
|
||||
function savePreference(pref: ThemePreference): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, pref);
|
||||
} catch {
|
||||
// quota exceeded or storage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function getSystemTheme(): ResolvedTheme {
|
||||
if (typeof globalThis.matchMedia !== "function") return "dark";
|
||||
return globalThis.matchMedia("(prefers-color-scheme: light)").matches
|
||||
? "light"
|
||||
: "dark";
|
||||
}
|
||||
|
||||
function resolve(pref: ThemePreference): ResolvedTheme {
|
||||
return pref === "system" ? getSystemTheme() : pref;
|
||||
}
|
||||
|
||||
function applyTheme(resolved: ResolvedTheme): void {
|
||||
document.documentElement.dataset.theme = resolved;
|
||||
}
|
||||
|
||||
function notifyAll(): void {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
// Apply on load
|
||||
applyTheme(resolve(currentPreference));
|
||||
|
||||
// Listen for OS preference changes
|
||||
if (typeof globalThis.matchMedia === "function") {
|
||||
globalThis
|
||||
.matchMedia("(prefers-color-scheme: light)")
|
||||
.addEventListener("change", () => {
|
||||
if (currentPreference === "system") {
|
||||
applyTheme(resolve("system"));
|
||||
notifyAll();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function subscribe(callback: () => void): () => void {
|
||||
listeners.add(callback);
|
||||
return () => listeners.delete(callback);
|
||||
}
|
||||
|
||||
function getSnapshot(): ThemePreference {
|
||||
return currentPreference;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const preference = useSyncExternalStore(subscribe, getSnapshot);
|
||||
const resolved = resolve(preference);
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(resolved);
|
||||
}, [resolved]);
|
||||
|
||||
const setPreference = useCallback((pref: ThemePreference) => {
|
||||
currentPreference = pref;
|
||||
savePreference(pref);
|
||||
applyTheme(resolve(pref));
|
||||
notifyAll();
|
||||
}, []);
|
||||
|
||||
return { preference, resolved, setPreference } as const;
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-background: #0f172a;
|
||||
--color-background: #0e1a2e;
|
||||
--color-foreground: #e2e8f0;
|
||||
--color-muted: #64748b;
|
||||
--color-muted: #7a8ba4;
|
||||
--color-muted-foreground: #94a3b8;
|
||||
--color-card: #1e293b;
|
||||
--color-card: #1a2e4a;
|
||||
--color-card-foreground: #e2e8f0;
|
||||
--color-border: #334155;
|
||||
--color-input: #334155;
|
||||
--color-border: #2a5088;
|
||||
--color-input: #2a5088;
|
||||
--color-primary: #3b82f6;
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-accent: #3b82f6;
|
||||
@@ -19,12 +19,47 @@
|
||||
--color-hover-neutral-bg: oklch(0.623 0.214 259 / 0.15);
|
||||
--color-hover-action-bg: var(--color-muted);
|
||||
--color-hover-destructive-bg: transparent;
|
||||
--color-stat-heading: #fbbf24;
|
||||
--color-stat-divider-from: oklch(0.5 0.1 65 / 0.6);
|
||||
--color-stat-divider-via: oklch(0.5 0.1 65 / 0.4);
|
||||
--color-hp-damage-hover-bg: oklch(0.25 0.05 25);
|
||||
--color-hp-heal-hover-bg: oklch(0.25 0.05 155);
|
||||
--color-active-row-bg: oklch(0.623 0.214 259 / 0.1);
|
||||
--color-active-row-border: oklch(0.623 0.214 259 / 0.4);
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--color-background: #eeecea;
|
||||
--color-foreground: #374151;
|
||||
--color-muted: #e0ddd9;
|
||||
--color-muted-foreground: #6b7280;
|
||||
--color-card: #f7f6f4;
|
||||
--color-card-foreground: #374151;
|
||||
--color-border: #ddd9d5;
|
||||
--color-input: #cdc8c3;
|
||||
--color-primary: #2563eb;
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-accent: #2563eb;
|
||||
--color-destructive: #dc2626;
|
||||
--color-hover-neutral: var(--color-primary);
|
||||
--color-hover-action: var(--color-primary);
|
||||
--color-hover-destructive: var(--color-destructive);
|
||||
--color-hover-neutral-bg: oklch(0.623 0.214 259 / 0.08);
|
||||
--color-hover-action-bg: var(--color-muted);
|
||||
--color-hover-destructive-bg: transparent;
|
||||
--color-stat-heading: #92400e;
|
||||
--color-stat-divider-from: oklch(0.55 0.1 65 / 0.5);
|
||||
--color-stat-divider-via: oklch(0.55 0.1 65 / 0.25);
|
||||
--color-hp-damage-hover-bg: #fef2f2;
|
||||
--color-hp-heal-hover-bg: #ecfdf5;
|
||||
--color-active-row-bg: oklch(0.623 0.214 259 / 0.08);
|
||||
--color-active-row-border: oklch(0.623 0.214 259 / 0.25);
|
||||
}
|
||||
|
||||
@keyframes concentration-shake {
|
||||
0% {
|
||||
translate: 0;
|
||||
@@ -169,6 +204,38 @@
|
||||
concentration-glow 1200ms ease-out;
|
||||
}
|
||||
|
||||
@utility card-glow {
|
||||
background-image: radial-gradient(
|
||||
ellipse at 50% 50%,
|
||||
oklch(0.35 0.05 250 / 0.5) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
box-shadow:
|
||||
0 0 15px -2px oklch(0.623 0.214 259 / 0.2),
|
||||
inset 0 1px 0 0 oklch(0.7 0.15 259 / 0.1);
|
||||
|
||||
[data-theme="light"] & {
|
||||
background-image: none;
|
||||
box-shadow: 0 1px 3px 0 oklch(0 0 0 / 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
@utility panel-glow {
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
oklch(0.35 0.05 250 / 0.4) 0%,
|
||||
transparent 40%
|
||||
);
|
||||
box-shadow:
|
||||
0 0 20px -2px oklch(0.623 0.214 259 / 0.15),
|
||||
inset 0 1px 0 0 oklch(0.7 0.15 259 / 0.1);
|
||||
|
||||
[data-theme="light"] & {
|
||||
background-image: none;
|
||||
box-shadow: -1px 0 6px 0 oklch(0 0 0 / 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-color: var(--color-border) transparent;
|
||||
scrollbar-width: thin;
|
||||
@@ -176,6 +243,16 @@
|
||||
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
background-image: radial-gradient(
|
||||
ellipse at 50% 40%,
|
||||
oklch(0.26 0.055 250) 0%,
|
||||
var(--color-background) 70%
|
||||
);
|
||||
background-attachment: fixed;
|
||||
color: var(--color-foreground);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
[data-theme="light"] body {
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import { App } from "./App.js";
|
||||
import {
|
||||
BestiaryProvider,
|
||||
BulkImportProvider,
|
||||
EncounterProvider,
|
||||
InitiativeRollsProvider,
|
||||
PlayerCharactersProvider,
|
||||
RulesEditionProvider,
|
||||
SidePanelProvider,
|
||||
ThemeProvider,
|
||||
} from "./contexts/index.js";
|
||||
import "./index.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<ThemeProvider>
|
||||
<RulesEditionProvider>
|
||||
<EncounterProvider>
|
||||
<BestiaryProvider>
|
||||
<PlayerCharactersProvider>
|
||||
<BulkImportProvider>
|
||||
<SidePanelProvider>
|
||||
<InitiativeRollsProvider>
|
||||
<App />
|
||||
</InitiativeRollsProvider>
|
||||
</SidePanelProvider>
|
||||
</BulkImportProvider>
|
||||
</PlayerCharactersProvider>
|
||||
</BestiaryProvider>
|
||||
</EncounterProvider>
|
||||
</RulesEditionProvider>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
|
||||
"files": {
|
||||
"includes": [
|
||||
"**",
|
||||
|
||||
256
docs/agents/research/2026-03-24-rules-edition-settings-modal.md
Normal file
256
docs/agents/research/2026-03-24-rules-edition-settings-modal.md
Normal file
@@ -0,0 +1,256 @@
|
||||
---
|
||||
date: "2026-03-24T10:22:04.341906+00:00"
|
||||
git_commit: cfd4aef724487a681e425cedfa08f3e89255f91a
|
||||
branch: main
|
||||
topic: "Rules edition setting for condition tooltips + settings modal"
|
||||
tags: [research, codebase, conditions, settings, theme, modal, issue-12]
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Research: Rules Edition Setting for Condition Tooltips + Settings Modal
|
||||
|
||||
## Research Question
|
||||
|
||||
Map the codebase for implementing issue #12: a rules edition setting (5e 2014 / 5.5e 2024) that controls condition tooltip descriptions, delivered via a new settings modal that also absorbs the existing theme toggle. Target spec: `specs/003-combatant-state/spec.md` (stories CC-3, CC-8, FR-095–FR-102).
|
||||
|
||||
## Summary
|
||||
|
||||
The implementation touches five areas: (1) the domain condition definitions, (2) the tooltip rendering in two web components, (3) the kebab overflow menu in the action bar, (4) the theme system (hook + context), and (5) a new settings modal following existing `<dialog>` patterns. The localStorage persistence pattern is well-established with a consistent `"initiative:<key>"` convention. The context provider tree in `main.tsx` is the integration point for a new settings context.
|
||||
|
||||
## Detailed Findings
|
||||
|
||||
### 1. Condition Definitions and Tooltip Data Flow
|
||||
|
||||
**Domain layer** — `packages/domain/src/conditions.ts`
|
||||
|
||||
The `ConditionDefinition` interface (line 18) carries a single `description: string` field. The `CONDITION_DEFINITIONS` array (line 26) holds all 15 conditions as `readonly` objects with `id`, `label`, `description`, `iconName`, and `color`. This is the single source of truth for condition data.
|
||||
|
||||
Exported types: `ConditionId` (union of 15 string literals), `ConditionDefinition`, `CONDITION_DEFINITIONS`, `VALID_CONDITION_IDS`.
|
||||
|
||||
**Web layer — condition-tags.tsx** (`apps/web/src/components/condition-tags.tsx`)
|
||||
|
||||
- Line 69: Looks up definition via `CONDITION_DEFINITIONS.find((d) => d.id === condId)`
|
||||
- Line 75: Passes tooltip content as `` `${def.label}: ${def.description}` `` — combines label + description into a single string
|
||||
- This is the tooltip shown when hovering active condition icons in the combatant row
|
||||
|
||||
**Web layer — condition-picker.tsx** (`apps/web/src/components/condition-picker.tsx`)
|
||||
|
||||
- Line 119: Iterates `CONDITION_DEFINITIONS.map(...)` directly
|
||||
- Line 125: Passes `content={def.description}` to Tooltip — description only, no label prefix
|
||||
- This is the tooltip shown when hovering conditions in the dropdown picker
|
||||
|
||||
**Key observation:** Both components read `def.description` directly from the imported domain constant. To make descriptions edition-aware, either (a) the domain type needs dual descriptions and consumers select by edition, or (b) a higher-level hook resolves the correct description before passing to components.
|
||||
|
||||
### 2. Tooltip Component
|
||||
|
||||
**File:** `apps/web/src/components/ui/tooltip.tsx`
|
||||
|
||||
- Props: `content: string`, `children: ReactNode`, optional `className`
|
||||
- Positioning: Uses `getBoundingClientRect()` to place tooltip 4px above the trigger element, centered horizontally
|
||||
- Rendered via `createPortal` to `document.body` at z-index 60
|
||||
- Max width: `max-w-64` (256px / 16rem) with `text-xs leading-snug`
|
||||
- Text wraps naturally within the max-width constraint — no explicit truncation
|
||||
- The tooltip accepts only `string` content, not ReactNode
|
||||
|
||||
The current descriptions are short (1-2 sentences). The 5e (2014) exhaustion description will be longer (6-level table as text), which may benefit from the existing 256px wrapping. No changes to the tooltip component itself should be needed.
|
||||
|
||||
### 3. Kebab Menu (Overflow Menu)
|
||||
|
||||
**OverflowMenu component** — `apps/web/src/components/ui/overflow-menu.tsx`
|
||||
|
||||
- Generic menu component accepting `items: readonly OverflowMenuItem[]`
|
||||
- Each item has: `icon: ReactNode`, `label: string`, `onClick: () => void`, optional `disabled` and `keepOpen`
|
||||
- Opens upward (`bottom-full`) from the kebab button, right-aligned
|
||||
- Close on click-outside (mousedown) and Escape key
|
||||
|
||||
**ActionBar integration** — `apps/web/src/components/action-bar.tsx`
|
||||
|
||||
- `buildOverflowItems()` function (line 231) constructs the menu items array
|
||||
- Current items in order:
|
||||
1. **Player Characters** (Users icon) — calls `opts.onManagePlayers`
|
||||
2. **Manage Sources** (Library icon) — calls `opts.onOpenSourceManager`
|
||||
3. **Import All Sources** (Import icon) — conditional on bestiary loaded
|
||||
4. **Theme cycle** (Monitor/Sun/Moon icon) — calls `opts.onCycleTheme`, uses `keepOpen: true`
|
||||
- Theme constants at lines 219-229: `THEME_ICONS` and `THEME_LABELS` maps
|
||||
- Line 293: `useThemeContext()` provides `preference` and `cycleTheme`
|
||||
- Line 529-537: Overflow items built with all options passed in
|
||||
|
||||
**To add a "Settings" item:** Add a new entry to `buildOverflowItems()` and remove the theme cycle entry. The new item would call a callback to open the settings modal.
|
||||
|
||||
### 4. Theme System
|
||||
|
||||
**Hook** — `apps/web/src/hooks/use-theme.ts`
|
||||
|
||||
- Module-level state: `currentPreference` initialized from localStorage on import (line 9)
|
||||
- `ThemePreference` type: `"system" | "light" | "dark"`
|
||||
- `ResolvedTheme` type: `"light" | "dark"`
|
||||
- Storage key: `"initiative:theme"` (line 6)
|
||||
- `loadPreference()` — reads localStorage, defaults to `"system"` (lines 11-19)
|
||||
- `savePreference()` — writes to localStorage, silent on error (lines 21-27)
|
||||
- `resolve()` — resolves "system" via `matchMedia("(prefers-color-scheme: light)")` (lines 29-38)
|
||||
- `applyTheme()` — sets `document.documentElement.dataset.theme` (lines 40-42)
|
||||
- Uses `useSyncExternalStore` for React integration (line 77)
|
||||
- Exposes: `preference`, `resolved`, `setPreference`, `cycleTheme`
|
||||
- OS preference change listener updates theme when preference is "system" (lines 54-63)
|
||||
|
||||
**Context** — `apps/web/src/contexts/theme-context.tsx`
|
||||
|
||||
- Simple wrapper: `ThemeProvider` calls `useTheme()` and provides via React context
|
||||
- `useThemeContext()` hook for consumers (line 15)
|
||||
|
||||
**For settings modal:** The theme system already exposes `setPreference(pref)` which is exactly what the settings modal needs — direct selection instead of cycling.
|
||||
|
||||
### 5. localStorage Persistence Patterns
|
||||
|
||||
All storage follows a consistent pattern:
|
||||
|
||||
| Key | Content | Format |
|
||||
|-----|---------|--------|
|
||||
| `initiative:encounter` | Full encounter state | JSON object |
|
||||
| `initiative:player-characters` | Player character array | JSON array |
|
||||
| `initiative:theme` | Theme preference | Plain string |
|
||||
|
||||
**Common patterns:**
|
||||
- Read: `try { localStorage.getItem(key) } catch { return default }`
|
||||
- Write: `try { localStorage.setItem(key, value) } catch { /* silent */ }`
|
||||
- Validation on read: type-check, range-check, reject invalid, return fallback
|
||||
- Bootstrap: `useState(initializeFunction)` where initializer loads from storage
|
||||
- Persistence: `useEffect([data], () => saveToStorage(data))`
|
||||
|
||||
**For rules edition:** Key would be `"initiative:rules-edition"`. Value would be a plain string (`"5e"` or `"5.5e"`), matching the theme pattern (simple string, not JSON). Default: `"5.5e"`.
|
||||
|
||||
### 6. Modal Patterns
|
||||
|
||||
Two modal implementations exist, both using HTML `<dialog>`:
|
||||
|
||||
**PlayerManagement** (`apps/web/src/components/player-management.tsx`)
|
||||
- Controlled by `open` prop
|
||||
- `useEffect` calls `dialog.showModal()` / `dialog.close()` based on `open`
|
||||
- Cancel event (Escape) prevented and routed to `onClose`
|
||||
- Backdrop click (mousedown on dialog element itself) routes to `onClose`
|
||||
- Styling: `card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50`
|
||||
- Header: title + X close button (ghost variant, muted foreground)
|
||||
|
||||
**CreatePlayerModal** (`apps/web/src/components/create-player-modal.tsx`)
|
||||
- Same `<dialog>` pattern with identical open/close/cancel/backdrop handling
|
||||
- Has form submission with validation and error display
|
||||
- Same styling as PlayerManagement
|
||||
|
||||
**Shared dialog pattern (extract from both):**
|
||||
```tsx
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) dialog.showModal();
|
||||
else if (!open && dialog.open) dialog.close();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
const handleCancel = (e: Event) => { e.preventDefault(); onClose(); };
|
||||
const handleBackdropClick = (e: MouseEvent) => { if (e.target === dialog) onClose(); };
|
||||
dialog.addEventListener("cancel", handleCancel);
|
||||
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||
return () => { /* cleanup */ };
|
||||
}, [onClose]);
|
||||
```
|
||||
|
||||
### 7. Context Provider Tree
|
||||
|
||||
**File:** `apps/web/src/main.tsx`
|
||||
|
||||
Provider nesting order (outermost first):
|
||||
1. `ThemeProvider`
|
||||
2. `EncounterProvider`
|
||||
3. `BestiaryProvider`
|
||||
4. `PlayerCharactersProvider`
|
||||
5. `BulkImportProvider`
|
||||
6. `SidePanelProvider`
|
||||
7. `InitiativeRollsProvider`
|
||||
|
||||
A new `SettingsProvider` (or `RulesEditionProvider`) would slot in early — before any component that reads condition descriptions. Since `ThemeProvider` is already the outermost, and the settings modal manages both theme and rules edition, one option is a `SettingsProvider` that wraps or replaces `ThemeProvider`.
|
||||
|
||||
### 8. 5e vs 5.5e Condition Text Differences
|
||||
|
||||
Based on research, here are the conditions with meaningful mechanical differences between editions. Conditions not listed are functionally identical across editions.
|
||||
|
||||
**Major changes:**
|
||||
|
||||
| Condition | 5e (2014) | 5.5e (2024) — current text |
|
||||
|---|---|---|
|
||||
| **Exhaustion** | 6 escalating levels: L1 disadvantage on ability checks, L2 speed halved, L3 disadvantage on attacks/saves, L4 HP max halved, L5 speed 0, L6 death | −level from d20 tests and spell save DCs. Speed reduced by 5 ft. × level. Death at 10 levels. (current) |
|
||||
| **Grappled** | Speed 0. Ends if grappler incapacitated or moved out of reach. | Speed 0, can't benefit from speed bonuses. Ends if grappler incapacitated or moved out of reach. (current — but 2024 also adds disadvantage on attacks vs non-grappler) |
|
||||
| **Invisible** | Can't be seen without magic/special sense. Heavily obscured. Advantage on attacks; disadvantage on attacks against. | 2024 broadened: can be gained from Hide action; grants Surprise (advantage on initiative), Concealed (unaffected by sight effects), attacks advantage/disadvantage. (current text is closer to 2014) |
|
||||
| **Stunned** | Incapacitated. Can't move. Speak falteringly. Auto-fail Str/Dex saves. Attacks against have advantage. | 2024: same but can still move (controversial). (current text says "Can't move" — matches 2014) |
|
||||
|
||||
**Moderate changes:**
|
||||
|
||||
| Condition | 5e (2014) | 5.5e (2024) |
|
||||
|---|---|---|
|
||||
| **Incapacitated** | Can't take actions or reactions. | Can't take actions, bonus actions, or reactions. Speed 0. Auto-fail Str/Dex saves. Attacks against have advantage. Concentration broken. (current is partial 2024) |
|
||||
| **Petrified** | Unaware of surroundings. | Aware of surroundings (2024 change). Current text doesn't mention awareness. |
|
||||
| **Poisoned** | Disadvantage on attacks and ability checks. | Same, but 2024 consolidates disease into poisoned. |
|
||||
|
||||
**Minor/identical:**
|
||||
|
||||
Blinded, Charmed ("harmful" → "damaging"), Deafened, Frightened, Paralyzed, Prone, Restrained, Unconscious — functionally identical between editions.
|
||||
|
||||
**Note on current descriptions:** The existing `conditions.ts` descriptions are a mix — exhaustion is clearly 2024, but stunned says "Can't move" which matches 2014. A full audit of each description against both editions will be needed during implementation to ensure accuracy.
|
||||
|
||||
## Code References
|
||||
|
||||
- `packages/domain/src/conditions.ts:18-24` — `ConditionDefinition` interface (single `description` field)
|
||||
- `packages/domain/src/conditions.ts:26-145` — `CONDITION_DEFINITIONS` array with current (mixed edition) descriptions
|
||||
- `apps/web/src/components/condition-tags.tsx:75` — Tooltip with `${def.label}: ${def.description}`
|
||||
- `apps/web/src/components/condition-picker.tsx:125` — Tooltip with `def.description`
|
||||
- `apps/web/src/components/ui/tooltip.tsx:1-55` — Tooltip component (string content, 256px max-width)
|
||||
- `apps/web/src/components/ui/overflow-menu.tsx:1-73` — Generic overflow menu
|
||||
- `apps/web/src/components/action-bar.tsx:231-274` — `buildOverflowItems()` (current menu items)
|
||||
- `apps/web/src/components/action-bar.tsx:293` — `useThemeContext()` usage in ActionBar
|
||||
- `apps/web/src/hooks/use-theme.ts:1-98` — Theme hook with localStorage, `useSyncExternalStore`, cycle/set
|
||||
- `apps/web/src/contexts/theme-context.tsx:1-19` — Theme context provider
|
||||
- `apps/web/src/main.tsx:17-35` — Provider nesting order
|
||||
- `apps/web/src/components/player-management.tsx:55-131` — `<dialog>` modal pattern (reference for settings modal)
|
||||
- `apps/web/src/components/create-player-modal.tsx:106-191` — Form-based `<dialog>` modal pattern
|
||||
- `apps/web/src/persistence/encounter-storage.ts` — localStorage persistence pattern (read/write/validate)
|
||||
- `apps/web/src/persistence/player-character-storage.ts` — localStorage persistence pattern
|
||||
|
||||
## Architecture Documentation
|
||||
|
||||
### Data Flow: Condition Description → Tooltip
|
||||
|
||||
```
|
||||
Domain: CONDITION_DEFINITIONS[].description (single string)
|
||||
↓ imported by
|
||||
Web: condition-tags.tsx → Tooltip content={`${label}: ${description}`}
|
||||
Web: condition-picker.tsx → Tooltip content={description}
|
||||
↓ rendered by
|
||||
UI: tooltip.tsx → createPortal → fixed-position div (max-w-64)
|
||||
```
|
||||
|
||||
### Settings/Preference Architecture
|
||||
|
||||
```
|
||||
localStorage → use-theme.ts (useSyncExternalStore) → theme-context.tsx → consumers
|
||||
localStorage → encounter-storage.ts → use-encounter.ts (useState) → encounter-context.tsx
|
||||
localStorage → player-character-storage.ts → use-player-characters.ts (useState) → pc-context.tsx
|
||||
```
|
||||
|
||||
### Modal Triggering Pattern
|
||||
|
||||
```
|
||||
ActionBar overflow menu item click
|
||||
→ callback prop (e.g., onManagePlayers)
|
||||
→ App.tsx calls imperative handle (e.g., playerCharacterRef.current.openManagement())
|
||||
→ Section component sets open state
|
||||
→ <dialog>.showModal() via useEffect
|
||||
```
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Current description accuracy:** The existing descriptions are a mix of 2014 and 2024 text (e.g., exhaustion is 2024, stunned "Can't move" is 2014). Both sets of descriptions need careful authoring against official sources during implementation.
|
||||
2. **Domain type change:** Should `ConditionDefinition` carry `description5e` and `description55e` fields, or should description resolution happen at the application/web layer? The domain-level approach is simpler and keeps the data co-located with condition definitions.
|
||||
3. **Settings context scope:** Should a new `SettingsProvider` manage both rules edition and theme, or should rules edition be its own context? The theme system already has its own well-structured hook/context; combining them may add unnecessary coupling.
|
||||
@@ -5,6 +5,6 @@
|
||||
"entry": ["scripts/*.mjs"]
|
||||
},
|
||||
"packages/*": {},
|
||||
"apps/*": {}
|
||||
"apps/web": {}
|
||||
}
|
||||
}
|
||||
|
||||
21
package.json
21
package.json
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.6.0",
|
||||
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"undici": ">=7.24.0"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.7",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@biomejs/biome": "2.4.8",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"jscpd": "^4.0.8",
|
||||
"knip": "^5.85.0",
|
||||
"lefthook": "^1.11.0",
|
||||
"oxlint": "^1.55.0",
|
||||
"oxlint-tsgolint": "^0.16.0",
|
||||
"knip": "^5.88.1",
|
||||
"lefthook": "^2.1.4",
|
||||
"oxlint": "^1.56.0",
|
||||
"oxlint-tsgolint": "^0.17.1",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.0.0"
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"scripts": {
|
||||
"prepare": "lefthook install",
|
||||
@@ -29,6 +29,9 @@
|
||||
"knip": "knip",
|
||||
"jscpd": "jscpd",
|
||||
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
|
||||
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && tsc --build && vitest run && jscpd"
|
||||
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
||||
"check:classnames": "node scripts/check-cn-classnames.mjs",
|
||||
"check:props": "node scripts/check-component-props.mjs",
|
||||
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && node scripts/check-component-props.mjs && tsc --build && vitest run && jscpd"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +161,48 @@ describe("rollAllInitiativeUseCase", () => {
|
||||
expect(store.saved).toBeNull();
|
||||
});
|
||||
|
||||
it("uses higher roll with advantage", () => {
|
||||
const enc = encounterWithCombatants([
|
||||
{ name: "A", creatureId: "creature-a" },
|
||||
]);
|
||||
const store = stubEncounterStore(enc);
|
||||
const creature = makeCreature("creature-a");
|
||||
|
||||
// Alternating rolls: 5, 15 → advantage picks 15
|
||||
// Dex 14 → modifier +2, so 15 + 2 = 17
|
||||
let call = 0;
|
||||
const result = rollAllInitiativeUseCase(
|
||||
store,
|
||||
() => (++call % 2 === 1 ? 5 : 15),
|
||||
(id) => (id === CREATURE_A ? creature : undefined),
|
||||
"advantage",
|
||||
);
|
||||
|
||||
expectSuccess(result);
|
||||
expect(requireSaved(store.saved).combatants[0].initiative).toBe(17);
|
||||
});
|
||||
|
||||
it("uses lower roll with disadvantage", () => {
|
||||
const enc = encounterWithCombatants([
|
||||
{ name: "A", creatureId: "creature-a" },
|
||||
]);
|
||||
const store = stubEncounterStore(enc);
|
||||
const creature = makeCreature("creature-a");
|
||||
|
||||
// Alternating rolls: 15, 5 → disadvantage picks 5
|
||||
// Dex 14 → modifier +2, so 5 + 2 = 7
|
||||
let call = 0;
|
||||
const result = rollAllInitiativeUseCase(
|
||||
store,
|
||||
() => (++call % 2 === 1 ? 15 : 5),
|
||||
(id) => (id === CREATURE_A ? creature : undefined),
|
||||
"disadvantage",
|
||||
);
|
||||
|
||||
expectSuccess(result);
|
||||
expect(requireSaved(store.saved).combatants[0].initiative).toBe(7);
|
||||
});
|
||||
|
||||
it("saves encounter once at the end", () => {
|
||||
const enc = encounterWithCombatants([
|
||||
{ name: "A", creatureId: "creature-a" },
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("rollInitiativeUseCase", () => {
|
||||
const result = rollInitiativeUseCase(
|
||||
store,
|
||||
combatantId("unknown"),
|
||||
10,
|
||||
[10],
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
@@ -80,7 +80,7 @@ describe("rollInitiativeUseCase", () => {
|
||||
const result = rollInitiativeUseCase(
|
||||
store,
|
||||
combatantId("Fighter"),
|
||||
10,
|
||||
[10],
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("rollInitiativeUseCase", () => {
|
||||
const result = rollInitiativeUseCase(
|
||||
store,
|
||||
combatantId("Goblin"),
|
||||
10,
|
||||
[10],
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
@@ -116,7 +116,7 @@ describe("rollInitiativeUseCase", () => {
|
||||
const result = rollInitiativeUseCase(
|
||||
store,
|
||||
combatantId("Goblin"),
|
||||
10,
|
||||
[10],
|
||||
(id) => (id === GOBLIN_ID ? creature : undefined),
|
||||
);
|
||||
|
||||
@@ -124,6 +124,42 @@ describe("rollInitiativeUseCase", () => {
|
||||
expect(requireSaved(store.saved).combatants[0].initiative).toBe(12);
|
||||
});
|
||||
|
||||
it("uses higher roll with advantage", () => {
|
||||
const creature = makeCreature();
|
||||
const enc = encounterWithCreatureLink("Goblin", GOBLIN_ID);
|
||||
const store = stubEncounterStore(enc);
|
||||
|
||||
// Dex 14 -> modifier +2, advantage picks max(5, 15) = 15, 15 + 2 = 17
|
||||
const result = rollInitiativeUseCase(
|
||||
store,
|
||||
combatantId("Goblin"),
|
||||
[5, 15],
|
||||
(id) => (id === GOBLIN_ID ? creature : undefined),
|
||||
"advantage",
|
||||
);
|
||||
|
||||
expect(isDomainError(result)).toBe(false);
|
||||
expect(requireSaved(store.saved).combatants[0].initiative).toBe(17);
|
||||
});
|
||||
|
||||
it("uses lower roll with disadvantage", () => {
|
||||
const creature = makeCreature();
|
||||
const enc = encounterWithCreatureLink("Goblin", GOBLIN_ID);
|
||||
const store = stubEncounterStore(enc);
|
||||
|
||||
// Dex 14 -> modifier +2, disadvantage picks min(5, 15) = 5, 5 + 2 = 7
|
||||
const result = rollInitiativeUseCase(
|
||||
store,
|
||||
combatantId("Goblin"),
|
||||
[5, 15],
|
||||
(id) => (id === GOBLIN_ID ? creature : undefined),
|
||||
"disadvantage",
|
||||
);
|
||||
|
||||
expect(isDomainError(result)).toBe(false);
|
||||
expect(requireSaved(store.saved).combatants[0].initiative).toBe(7);
|
||||
});
|
||||
|
||||
it("applies initiative proficiency bonus correctly", () => {
|
||||
// CR 5 -> PB 3, dex 16 -> mod +3, initiativeProficiency 1
|
||||
// modifier = 3 + 1*3 = 6, roll 8 + 6 = 14
|
||||
@@ -145,7 +181,7 @@ describe("rollInitiativeUseCase", () => {
|
||||
const result = rollInitiativeUseCase(
|
||||
store,
|
||||
combatantId("Monster"),
|
||||
8,
|
||||
[8],
|
||||
(id) => (id === GOBLIN_ID ? creature : undefined),
|
||||
);
|
||||
|
||||
|
||||
@@ -21,5 +21,6 @@ export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
|
||||
export { setAcUseCase } from "./set-ac-use-case.js";
|
||||
export { setHpUseCase } from "./set-hp-use-case.js";
|
||||
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
||||
export { setTempHpUseCase } from "./set-temp-hp-use-case.js";
|
||||
export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
|
||||
export { toggleConditionUseCase } from "./toggle-condition-use-case.js";
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
type RollMode,
|
||||
rollInitiative,
|
||||
selectRoll,
|
||||
setInitiative,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
@@ -19,6 +21,7 @@ export function rollAllInitiativeUseCase(
|
||||
store: EncounterStore,
|
||||
rollDice: () => number,
|
||||
getCreature: (id: CreatureId) => Creature | undefined,
|
||||
mode: RollMode = "normal",
|
||||
): RollAllResult | DomainError {
|
||||
let encounter = store.get();
|
||||
const allEvents: DomainEvent[] = [];
|
||||
@@ -39,7 +42,10 @@ export function rollAllInitiativeUseCase(
|
||||
cr: creature.cr,
|
||||
initiativeProficiency: creature.initiativeProficiency,
|
||||
});
|
||||
const value = rollInitiative(rollDice(), modifier);
|
||||
const roll1 = rollDice();
|
||||
const effectiveRoll =
|
||||
mode === "normal" ? roll1 : selectRoll(roll1, rollDice(), mode);
|
||||
const value = rollInitiative(effectiveRoll, modifier);
|
||||
|
||||
if (isDomainError(value)) {
|
||||
return value;
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
type RollMode,
|
||||
rollInitiative,
|
||||
selectRoll,
|
||||
setInitiative,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
@@ -14,8 +16,9 @@ import type { EncounterStore } from "./ports.js";
|
||||
export function rollInitiativeUseCase(
|
||||
store: EncounterStore,
|
||||
combatantId: CombatantId,
|
||||
diceRoll: number,
|
||||
diceRolls: readonly [number, ...number[]],
|
||||
getCreature: (id: CreatureId) => Creature | undefined,
|
||||
mode: RollMode = "normal",
|
||||
): DomainEvent[] | DomainError {
|
||||
const encounter = store.get();
|
||||
const combatant = encounter.combatants.find((c) => c.id === combatantId);
|
||||
@@ -50,7 +53,11 @@ export function rollInitiativeUseCase(
|
||||
cr: creature.cr,
|
||||
initiativeProficiency: creature.initiativeProficiency,
|
||||
});
|
||||
const value = rollInitiative(diceRoll, modifier);
|
||||
const effectiveRoll =
|
||||
mode === "normal"
|
||||
? diceRolls[0]
|
||||
: selectRoll(diceRolls[0], diceRolls[1] ?? diceRolls[0], mode);
|
||||
const value = rollInitiative(effectiveRoll, modifier);
|
||||
|
||||
if (isDomainError(value)) {
|
||||
return value;
|
||||
|
||||
24
packages/application/src/set-temp-hp-use-case.ts
Normal file
24
packages/application/src/set-temp-hp-use-case.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
setTempHp,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
|
||||
export function setTempHpUseCase(
|
||||
store: EncounterStore,
|
||||
combatantId: CombatantId,
|
||||
tempHp: number | undefined,
|
||||
): DomainEvent[] | DomainError {
|
||||
const encounter = store.get();
|
||||
const result = setTempHp(encounter, combatantId, tempHp);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save(result.encounter);
|
||||
return result.events;
|
||||
}
|
||||
@@ -6,12 +6,18 @@ import { expectDomainError } from "./test-helpers.js";
|
||||
|
||||
function makeCombatant(
|
||||
name: string,
|
||||
opts?: { maxHp: number; currentHp: number },
|
||||
opts?: { maxHp: number; currentHp: number; tempHp?: number },
|
||||
): Combatant {
|
||||
return {
|
||||
id: combatantId(name),
|
||||
name,
|
||||
...(opts ? { maxHp: opts.maxHp, currentHp: opts.currentHp } : {}),
|
||||
...(opts
|
||||
? {
|
||||
maxHp: opts.maxHp,
|
||||
currentHp: opts.currentHp,
|
||||
tempHp: opts.tempHp,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,4 +158,96 @@ describe("adjustHp", () => {
|
||||
expect(encounter.combatants[0].currentHp).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("temporary HP absorption", () => {
|
||||
it("damage fully absorbed by temp HP — currentHp unchanged", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 8 }),
|
||||
]);
|
||||
const { encounter } = successResult(e, "A", -5);
|
||||
expect(encounter.combatants[0].currentHp).toBe(15);
|
||||
expect(encounter.combatants[0].tempHp).toBe(3);
|
||||
});
|
||||
|
||||
it("damage partially absorbed by temp HP — overflow reduces currentHp", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 3 }),
|
||||
]);
|
||||
const { encounter } = successResult(e, "A", -10);
|
||||
expect(encounter.combatants[0].tempHp).toBeUndefined();
|
||||
expect(encounter.combatants[0].currentHp).toBe(8);
|
||||
});
|
||||
|
||||
it("damage exceeding both temp HP and currentHp — both reach minimum", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 5, tempHp: 3 }),
|
||||
]);
|
||||
const { encounter } = successResult(e, "A", -50);
|
||||
expect(encounter.combatants[0].tempHp).toBeUndefined();
|
||||
expect(encounter.combatants[0].currentHp).toBe(0);
|
||||
});
|
||||
|
||||
it("healing does not restore temp HP", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 3 }),
|
||||
]);
|
||||
const { encounter } = successResult(e, "A", 5);
|
||||
expect(encounter.combatants[0].currentHp).toBe(15);
|
||||
expect(encounter.combatants[0].tempHp).toBe(3);
|
||||
});
|
||||
|
||||
it("temp HP cleared to undefined when fully depleted", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 5 }),
|
||||
]);
|
||||
const { encounter } = successResult(e, "A", -5);
|
||||
expect(encounter.combatants[0].tempHp).toBeUndefined();
|
||||
expect(encounter.combatants[0].currentHp).toBe(15);
|
||||
});
|
||||
|
||||
it("emits only TempHpSet when damage fully absorbed", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 8 }),
|
||||
]);
|
||||
const { events } = successResult(e, "A", -3);
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "TempHpSet",
|
||||
combatantId: combatantId("A"),
|
||||
previousTempHp: 8,
|
||||
newTempHp: 5,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits both TempHpSet and CurrentHpAdjusted when damage overflows", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 3 }),
|
||||
]);
|
||||
const { events } = successResult(e, "A", -10);
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]).toEqual({
|
||||
type: "TempHpSet",
|
||||
combatantId: combatantId("A"),
|
||||
previousTempHp: 3,
|
||||
newTempHp: undefined,
|
||||
});
|
||||
expect(events[1]).toEqual({
|
||||
type: "CurrentHpAdjusted",
|
||||
combatantId: combatantId("A"),
|
||||
previousHp: 15,
|
||||
newHp: 8,
|
||||
delta: -10,
|
||||
});
|
||||
});
|
||||
|
||||
it("damage with no temp HP works as before", () => {
|
||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
||||
const { encounter, events } = successResult(e, "A", -5);
|
||||
expect(encounter.combatants[0].currentHp).toBe(10);
|
||||
expect(encounter.combatants[0].tempHp).toBeUndefined();
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].type).toBe("CurrentHpAdjusted");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
44
packages/domain/src/__tests__/conditions.test.ts
Normal file
44
packages/domain/src/__tests__/conditions.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CONDITION_DEFINITIONS,
|
||||
getConditionDescription,
|
||||
} from "../conditions.js";
|
||||
|
||||
function findCondition(id: string) {
|
||||
const def = CONDITION_DEFINITIONS.find((d) => d.id === id);
|
||||
if (!def) throw new Error(`Condition ${id} not found`);
|
||||
return def;
|
||||
}
|
||||
|
||||
describe("getConditionDescription", () => {
|
||||
it("returns 5.5e description by default", () => {
|
||||
const exhaustion = findCondition("exhaustion");
|
||||
expect(getConditionDescription(exhaustion, "5.5e")).toBe(
|
||||
exhaustion.description,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 5e description when edition is 5e", () => {
|
||||
const exhaustion = findCondition("exhaustion");
|
||||
expect(getConditionDescription(exhaustion, "5e")).toBe(
|
||||
exhaustion.description5e,
|
||||
);
|
||||
});
|
||||
|
||||
it("every condition has both descriptions", () => {
|
||||
for (const def of CONDITION_DEFINITIONS) {
|
||||
expect(def.description).toBeTruthy();
|
||||
expect(def.description5e).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("conditions with identical rules share the same text", () => {
|
||||
const blinded = findCondition("blinded");
|
||||
expect(blinded.description).toBe(blinded.description5e);
|
||||
});
|
||||
|
||||
it("conditions with different rules have different text", () => {
|
||||
const exhaustion = findCondition("exhaustion");
|
||||
expect(exhaustion.description).not.toBe(exhaustion.description5e);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rollInitiative } from "../roll-initiative.js";
|
||||
import { rollInitiative, selectRoll } from "../roll-initiative.js";
|
||||
import { isDomainError } from "../types.js";
|
||||
import { expectDomainError } from "./test-helpers.js";
|
||||
|
||||
@@ -63,3 +63,31 @@ describe("rollInitiative", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectRoll", () => {
|
||||
it("normal mode returns the first roll", () => {
|
||||
expect(selectRoll(8, 15, "normal")).toBe(8);
|
||||
});
|
||||
|
||||
it("advantage returns the higher roll", () => {
|
||||
expect(selectRoll(8, 15, "advantage")).toBe(15);
|
||||
});
|
||||
|
||||
it("advantage returns the higher roll (reversed)", () => {
|
||||
expect(selectRoll(15, 8, "advantage")).toBe(15);
|
||||
});
|
||||
|
||||
it("disadvantage returns the lower roll", () => {
|
||||
expect(selectRoll(8, 15, "disadvantage")).toBe(8);
|
||||
});
|
||||
|
||||
it("disadvantage returns the lower roll (reversed)", () => {
|
||||
expect(selectRoll(15, 8, "disadvantage")).toBe(8);
|
||||
});
|
||||
|
||||
it("equal rolls return the same value for all modes", () => {
|
||||
expect(selectRoll(12, 12, "normal")).toBe(12);
|
||||
expect(selectRoll(12, 12, "advantage")).toBe(12);
|
||||
expect(selectRoll(12, 12, "disadvantage")).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,6 +69,34 @@ describe("setHp", () => {
|
||||
expect(encounter.combatants[0].maxHp).toBeUndefined();
|
||||
expect(encounter.combatants[0].currentHp).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears tempHp when maxHp is cleared", () => {
|
||||
const e = enc([
|
||||
{
|
||||
id: combatantId("A"),
|
||||
name: "A",
|
||||
maxHp: 20,
|
||||
currentHp: 15,
|
||||
tempHp: 5,
|
||||
},
|
||||
]);
|
||||
const { encounter } = successResult(e, "A", undefined);
|
||||
expect(encounter.combatants[0].tempHp).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves tempHp when maxHp is updated", () => {
|
||||
const e = enc([
|
||||
{
|
||||
id: combatantId("A"),
|
||||
name: "A",
|
||||
maxHp: 20,
|
||||
currentHp: 15,
|
||||
tempHp: 5,
|
||||
},
|
||||
]);
|
||||
const { encounter } = successResult(e, "A", 25);
|
||||
expect(encounter.combatants[0].tempHp).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invariants", () => {
|
||||
|
||||
182
packages/domain/src/__tests__/set-temp-hp.test.ts
Normal file
182
packages/domain/src/__tests__/set-temp-hp.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { setTempHp } from "../set-temp-hp.js";
|
||||
import type { Combatant, Encounter } from "../types.js";
|
||||
import { combatantId, isDomainError } from "../types.js";
|
||||
import { expectDomainError } from "./test-helpers.js";
|
||||
|
||||
function makeCombatant(
|
||||
name: string,
|
||||
opts?: { maxHp: number; currentHp: number; tempHp?: number },
|
||||
): Combatant {
|
||||
return {
|
||||
id: combatantId(name),
|
||||
name,
|
||||
...(opts
|
||||
? {
|
||||
maxHp: opts.maxHp,
|
||||
currentHp: opts.currentHp,
|
||||
tempHp: opts.tempHp,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function enc(combatants: Combatant[]): Encounter {
|
||||
return { combatants, activeIndex: 0, roundNumber: 1 };
|
||||
}
|
||||
|
||||
function successResult(
|
||||
encounter: Encounter,
|
||||
id: string,
|
||||
tempHp: number | undefined,
|
||||
) {
|
||||
const result = setTempHp(encounter, combatantId(id), tempHp);
|
||||
if (isDomainError(result)) {
|
||||
throw new Error(`Expected success, got error: ${result.message}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("setTempHp", () => {
|
||||
describe("acceptance scenarios", () => {
|
||||
it("sets temp HP on a combatant with HP tracking enabled", () => {
|
||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
||||
const { encounter } = successResult(e, "A", 8);
|
||||
expect(encounter.combatants[0].tempHp).toBe(8);
|
||||
});
|
||||
|
||||
it("keeps higher value when existing temp HP is greater", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 5 }),
|
||||
]);
|
||||
const { encounter } = successResult(e, "A", 3);
|
||||
expect(encounter.combatants[0].tempHp).toBe(5);
|
||||
});
|
||||
|
||||
it("replaces when new value is higher", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 3 }),
|
||||
]);
|
||||
const { encounter } = successResult(e, "A", 7);
|
||||
expect(encounter.combatants[0].tempHp).toBe(7);
|
||||
});
|
||||
|
||||
it("clears temp HP when set to undefined", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 5 }),
|
||||
]);
|
||||
const { encounter } = successResult(e, "A", undefined);
|
||||
expect(encounter.combatants[0].tempHp).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("invariants", () => {
|
||||
it("is pure — same input produces same output", () => {
|
||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
||||
const r1 = setTempHp(e, combatantId("A"), 5);
|
||||
const r2 = setTempHp(e, combatantId("A"), 5);
|
||||
expect(r1).toEqual(r2);
|
||||
});
|
||||
|
||||
it("does not mutate input encounter", () => {
|
||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
||||
const original = JSON.parse(JSON.stringify(e));
|
||||
setTempHp(e, combatantId("A"), 5);
|
||||
expect(e).toEqual(original);
|
||||
});
|
||||
|
||||
it("emits TempHpSet event with correct shape", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 3 }),
|
||||
]);
|
||||
const { events } = successResult(e, "A", 7);
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "TempHpSet",
|
||||
combatantId: combatantId("A"),
|
||||
previousTempHp: 3,
|
||||
newTempHp: 7,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves activeIndex and roundNumber", () => {
|
||||
const e = {
|
||||
combatants: [
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 10 }),
|
||||
makeCombatant("B"),
|
||||
],
|
||||
activeIndex: 1,
|
||||
roundNumber: 5,
|
||||
};
|
||||
const { encounter } = successResult(e, "A", 5);
|
||||
expect(encounter.activeIndex).toBe(1);
|
||||
expect(encounter.roundNumber).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error cases", () => {
|
||||
it("returns error for nonexistent combatant", () => {
|
||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||
const result = setTempHp(e, combatantId("Z"), 5);
|
||||
expectDomainError(result, "combatant-not-found");
|
||||
});
|
||||
|
||||
it("returns error when HP tracking is not enabled", () => {
|
||||
const e = enc([makeCombatant("A")]);
|
||||
const result = setTempHp(e, combatantId("A"), 5);
|
||||
expectDomainError(result, "no-hp-tracking");
|
||||
});
|
||||
|
||||
it("rejects temp HP of 0", () => {
|
||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||
const result = setTempHp(e, combatantId("A"), 0);
|
||||
expectDomainError(result, "invalid-temp-hp");
|
||||
});
|
||||
|
||||
it("rejects negative temp HP", () => {
|
||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||
const result = setTempHp(e, combatantId("A"), -3);
|
||||
expectDomainError(result, "invalid-temp-hp");
|
||||
});
|
||||
|
||||
it("rejects non-integer temp HP", () => {
|
||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||
const result = setTempHp(e, combatantId("A"), 2.5);
|
||||
expectDomainError(result, "invalid-temp-hp");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("does not affect other combatants", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 15 }),
|
||||
makeCombatant("B", { maxHp: 30, currentHp: 25, tempHp: 4 }),
|
||||
]);
|
||||
const { encounter } = successResult(e, "A", 5);
|
||||
expect(encounter.combatants[1].tempHp).toBe(4);
|
||||
});
|
||||
|
||||
it("does not affect currentHp or maxHp", () => {
|
||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
||||
const { encounter } = successResult(e, "A", 8);
|
||||
expect(encounter.combatants[0].maxHp).toBe(20);
|
||||
expect(encounter.combatants[0].currentHp).toBe(15);
|
||||
});
|
||||
|
||||
it("event reflects no change when existing value equals new value", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 5 }),
|
||||
]);
|
||||
const { events } = successResult(e, "A", 5);
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "TempHpSet",
|
||||
combatantId: combatantId("A"),
|
||||
previousTempHp: 5,
|
||||
newTempHp: 5,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -54,24 +54,52 @@ export function adjustHp(
|
||||
}
|
||||
|
||||
const previousHp = target.currentHp;
|
||||
const newHp = Math.max(0, Math.min(target.maxHp, previousHp + delta));
|
||||
const previousTempHp = target.tempHp ?? 0;
|
||||
let newTempHp = previousTempHp;
|
||||
let effectiveDelta = delta;
|
||||
|
||||
if (delta < 0 && previousTempHp > 0) {
|
||||
const absorbed = Math.min(previousTempHp, Math.abs(delta));
|
||||
newTempHp = previousTempHp - absorbed;
|
||||
effectiveDelta = delta + absorbed;
|
||||
}
|
||||
|
||||
const newHp = Math.max(
|
||||
0,
|
||||
Math.min(target.maxHp, previousHp + effectiveDelta),
|
||||
);
|
||||
|
||||
const events: DomainEvent[] = [];
|
||||
|
||||
if (newTempHp !== previousTempHp) {
|
||||
events.push({
|
||||
type: "TempHpSet",
|
||||
combatantId,
|
||||
previousTempHp: previousTempHp || undefined,
|
||||
newTempHp: newTempHp || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (newHp !== previousHp) {
|
||||
events.push({
|
||||
type: "CurrentHpAdjusted",
|
||||
combatantId,
|
||||
previousHp,
|
||||
newHp,
|
||||
delta,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
encounter: {
|
||||
combatants: encounter.combatants.map((c) =>
|
||||
c.id === combatantId ? { ...c, currentHp: newHp } : c,
|
||||
c.id === combatantId
|
||||
? { ...c, currentHp: newHp, tempHp: newTempHp || undefined }
|
||||
: c,
|
||||
),
|
||||
activeIndex: encounter.activeIndex,
|
||||
roundNumber: encounter.roundNumber,
|
||||
},
|
||||
events: [
|
||||
{
|
||||
type: "CurrentHpAdjusted",
|
||||
combatantId,
|
||||
previousHp,
|
||||
newHp,
|
||||
delta,
|
||||
},
|
||||
],
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,66 +15,167 @@ export type ConditionId =
|
||||
| "stunned"
|
||||
| "unconscious";
|
||||
|
||||
export type RulesEdition = "5e" | "5.5e";
|
||||
|
||||
export interface ConditionDefinition {
|
||||
readonly id: ConditionId;
|
||||
readonly label: string;
|
||||
readonly description: string;
|
||||
readonly description5e: string;
|
||||
readonly iconName: string;
|
||||
readonly color: string;
|
||||
}
|
||||
|
||||
export function getConditionDescription(
|
||||
def: ConditionDefinition,
|
||||
edition: RulesEdition,
|
||||
): string {
|
||||
return edition === "5e" ? def.description5e : def.description;
|
||||
}
|
||||
|
||||
export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
{ id: "blinded", label: "Blinded", iconName: "EyeOff", color: "neutral" },
|
||||
{ id: "charmed", label: "Charmed", iconName: "Heart", color: "pink" },
|
||||
{ id: "deafened", label: "Deafened", iconName: "EarOff", color: "neutral" },
|
||||
{
|
||||
id: "blinded",
|
||||
label: "Blinded",
|
||||
description:
|
||||
"Can't see. Auto-fail sight checks. Attacks have Disadvantage; attacks against have Advantage.",
|
||||
description5e:
|
||||
"Can't see. Auto-fail sight checks. Attacks have Disadvantage; attacks against have Advantage.",
|
||||
iconName: "EyeOff",
|
||||
color: "neutral",
|
||||
},
|
||||
{
|
||||
id: "charmed",
|
||||
label: "Charmed",
|
||||
description:
|
||||
"Can't attack or target the charmer with harmful abilities. Charmer has Advantage on social checks.",
|
||||
description5e:
|
||||
"Can't attack or target the charmer with harmful abilities. Charmer has Advantage on social checks.",
|
||||
iconName: "Heart",
|
||||
color: "pink",
|
||||
},
|
||||
{
|
||||
id: "deafened",
|
||||
label: "Deafened",
|
||||
description: "Can't hear. Auto-fail hearing checks.",
|
||||
description5e: "Can't hear. Auto-fail hearing checks.",
|
||||
iconName: "EarOff",
|
||||
color: "neutral",
|
||||
},
|
||||
{
|
||||
id: "exhaustion",
|
||||
label: "Exhaustion",
|
||||
description:
|
||||
"D20 Tests reduced by 2 \u00D7 exhaustion level.\nSpeed reduced by 5 ft. \u00D7 level.\nLong rest removes 1 level.\nDeath at 6 levels.",
|
||||
description5e:
|
||||
"L1: Disadvantage on ability checks\nL2: Speed halved\nL3: Disadvantage on attacks and saves\nL4: HP max halved\nL5: Speed 0\nL6: Death\nLong rest removes 1 level.",
|
||||
iconName: "BatteryLow",
|
||||
color: "amber",
|
||||
},
|
||||
{
|
||||
id: "frightened",
|
||||
label: "Frightened",
|
||||
description:
|
||||
"Disadvantage on ability checks and attacks while source of fear is in line of sight. Can't willingly move closer to the source.",
|
||||
description5e:
|
||||
"Disadvantage on ability checks and attacks while source of fear is in line of sight. Can't willingly move closer to the source.",
|
||||
iconName: "Siren",
|
||||
color: "orange",
|
||||
},
|
||||
{ id: "grappled", label: "Grappled", iconName: "Hand", color: "neutral" },
|
||||
{
|
||||
id: "grappled",
|
||||
label: "Grappled",
|
||||
description:
|
||||
"Speed 0. Disadvantage on attacks against targets other than the grappler. Grappler can drag you (extra movement cost). Ends if grappler is Incapacitated or you leave their reach.",
|
||||
description5e:
|
||||
"Speed 0. Ends if grappler is Incapacitated or moved out of reach.",
|
||||
iconName: "Hand",
|
||||
color: "neutral",
|
||||
},
|
||||
{
|
||||
id: "incapacitated",
|
||||
label: "Incapacitated",
|
||||
description:
|
||||
"Can't take Actions, Bonus Actions, or Reactions. Can't speak. Concentration is broken. Disadvantage on Initiative.",
|
||||
description5e: "Can't take Actions or Reactions.",
|
||||
iconName: "Ban",
|
||||
color: "gray",
|
||||
},
|
||||
{
|
||||
id: "invisible",
|
||||
label: "Invisible",
|
||||
description:
|
||||
"Can't be seen. Advantage on Initiative. Not affected by effects requiring sight (unless caster sees you). Attacks have Advantage; attacks against have Disadvantage.",
|
||||
description5e:
|
||||
"Impossible to see without magic or special sense. Heavily Obscured. Attacks have Advantage; attacks against have Disadvantage.",
|
||||
iconName: "Ghost",
|
||||
color: "violet",
|
||||
},
|
||||
{
|
||||
id: "paralyzed",
|
||||
label: "Paralyzed",
|
||||
description:
|
||||
"Incapacitated. Can't move or speak. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||
description5e:
|
||||
"Incapacitated. Can't move or speak. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||
iconName: "ZapOff",
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
id: "petrified",
|
||||
label: "Petrified",
|
||||
description:
|
||||
"Turned to stone. Weight \u00D710. Incapacitated. Can't move or speak. Attacks against have Advantage. Auto-fail Str/Dex saves. Resistant to all damage. Immune to poison and disease.",
|
||||
description5e:
|
||||
"Turned to stone. Weight \u00D710. Incapacitated. Can't move or speak. Attacks against have Advantage. Auto-fail Str/Dex saves. Resistant to all damage. Immune to poison and disease.",
|
||||
iconName: "Gem",
|
||||
color: "slate",
|
||||
},
|
||||
{ id: "poisoned", label: "Poisoned", iconName: "Droplet", color: "green" },
|
||||
{ id: "prone", label: "Prone", iconName: "ArrowDown", color: "neutral" },
|
||||
{
|
||||
id: "poisoned",
|
||||
label: "Poisoned",
|
||||
description: "Disadvantage on attack rolls and ability checks.",
|
||||
description5e: "Disadvantage on attack rolls and ability checks.",
|
||||
iconName: "Droplet",
|
||||
color: "green",
|
||||
},
|
||||
{
|
||||
id: "prone",
|
||||
label: "Prone",
|
||||
description:
|
||||
"Can only crawl (costs extra movement). Disadvantage on attacks. Attacks within 5 ft. have Advantage; ranged attacks have Disadvantage. Standing up costs half movement.",
|
||||
description5e:
|
||||
"Can only crawl (costs extra movement). Disadvantage on attacks. Attacks within 5 ft. have Advantage; ranged attacks have Disadvantage. Standing up costs half movement.",
|
||||
iconName: "ArrowDown",
|
||||
color: "neutral",
|
||||
},
|
||||
{
|
||||
id: "restrained",
|
||||
label: "Restrained",
|
||||
description:
|
||||
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
|
||||
description5e:
|
||||
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
|
||||
iconName: "Link",
|
||||
color: "neutral",
|
||||
},
|
||||
{ id: "stunned", label: "Stunned", iconName: "Sparkles", color: "yellow" },
|
||||
{
|
||||
id: "stunned",
|
||||
label: "Stunned",
|
||||
description:
|
||||
"Incapacitated (can't act or speak). Auto-fail Str/Dex saves. Attacks against have Advantage.",
|
||||
description5e:
|
||||
"Incapacitated. Can't move. Can speak only falteringly. Auto-fail Str/Dex saves. Attacks against have Advantage.",
|
||||
iconName: "Sparkles",
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
id: "unconscious",
|
||||
label: "Unconscious",
|
||||
description:
|
||||
"Incapacitated. Speed 0. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||
description5e:
|
||||
"Incapacitated. Speed 0. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||
iconName: "Moon",
|
||||
color: "indigo",
|
||||
},
|
||||
|
||||
@@ -58,6 +58,13 @@ export interface CurrentHpAdjusted {
|
||||
readonly delta: number;
|
||||
}
|
||||
|
||||
export interface TempHpSet {
|
||||
readonly type: "TempHpSet";
|
||||
readonly combatantId: CombatantId;
|
||||
readonly previousTempHp: number | undefined;
|
||||
readonly newTempHp: number | undefined;
|
||||
}
|
||||
|
||||
export interface TurnRetreated {
|
||||
readonly type: "TurnRetreated";
|
||||
readonly previousCombatantId: CombatantId;
|
||||
@@ -132,6 +139,7 @@ export type DomainEvent =
|
||||
| InitiativeSet
|
||||
| MaxHpSet
|
||||
| CurrentHpAdjusted
|
||||
| TempHpSet
|
||||
| TurnRetreated
|
||||
| RoundRetreated
|
||||
| AcSet
|
||||
|
||||
@@ -10,6 +10,8 @@ export {
|
||||
CONDITION_DEFINITIONS,
|
||||
type ConditionDefinition,
|
||||
type ConditionId,
|
||||
getConditionDescription,
|
||||
type RulesEdition,
|
||||
VALID_CONDITION_IDS,
|
||||
} from "./conditions.js";
|
||||
export {
|
||||
@@ -60,6 +62,7 @@ export type {
|
||||
PlayerCharacterUpdated,
|
||||
RoundAdvanced,
|
||||
RoundRetreated,
|
||||
TempHpSet,
|
||||
TurnAdvanced,
|
||||
TurnRetreated,
|
||||
} from "./events.js";
|
||||
@@ -84,13 +87,18 @@ export {
|
||||
removeCombatant,
|
||||
} from "./remove-combatant.js";
|
||||
export { retreatTurn } from "./retreat-turn.js";
|
||||
export { rollInitiative } from "./roll-initiative.js";
|
||||
export {
|
||||
type RollMode,
|
||||
rollInitiative,
|
||||
selectRoll,
|
||||
} from "./roll-initiative.js";
|
||||
export { type SetAcSuccess, setAc } from "./set-ac.js";
|
||||
export { type SetHpSuccess, setHp } from "./set-hp.js";
|
||||
export {
|
||||
type SetInitiativeSuccess,
|
||||
setInitiative,
|
||||
} from "./set-initiative.js";
|
||||
export { type SetTempHpSuccess, setTempHp } from "./set-temp-hp.js";
|
||||
export {
|
||||
type ToggleConcentrationSuccess,
|
||||
toggleConcentration,
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import type { DomainError } from "./types.js";
|
||||
|
||||
export type RollMode = "normal" | "advantage" | "disadvantage";
|
||||
|
||||
/**
|
||||
* Selects the effective roll from two dice values based on the roll mode.
|
||||
* Advantage takes the higher, disadvantage takes the lower.
|
||||
*/
|
||||
export function selectRoll(
|
||||
roll1: number,
|
||||
roll2: number,
|
||||
mode: RollMode,
|
||||
): number {
|
||||
if (mode === "advantage") return Math.max(roll1, roll2);
|
||||
if (mode === "disadvantage") return Math.min(roll1, roll2);
|
||||
return roll1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function that computes initiative from a resolved dice roll and modifier.
|
||||
* The dice roll must be an integer in [1, 20].
|
||||
|
||||
@@ -66,7 +66,12 @@ export function setHp(
|
||||
encounter: {
|
||||
combatants: encounter.combatants.map((c) =>
|
||||
c.id === combatantId
|
||||
? { ...c, maxHp: newMaxHp, currentHp: newCurrentHp }
|
||||
? {
|
||||
...c,
|
||||
maxHp: newMaxHp,
|
||||
currentHp: newCurrentHp,
|
||||
tempHp: newMaxHp === undefined ? undefined : c.tempHp,
|
||||
}
|
||||
: c,
|
||||
),
|
||||
activeIndex: encounter.activeIndex,
|
||||
|
||||
78
packages/domain/src/set-temp-hp.ts
Normal file
78
packages/domain/src/set-temp-hp.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
||||
|
||||
export interface SetTempHpSuccess {
|
||||
readonly encounter: Encounter;
|
||||
readonly events: DomainEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function that sets or clears a combatant's temporary HP.
|
||||
*
|
||||
* - Setting tempHp when the combatant already has tempHp keeps the higher value.
|
||||
* - Clearing tempHp (undefined) removes temp HP entirely.
|
||||
* - Requires HP tracking to be enabled (maxHp must be set).
|
||||
*/
|
||||
export function setTempHp(
|
||||
encounter: Encounter,
|
||||
combatantId: CombatantId,
|
||||
tempHp: number | undefined,
|
||||
): SetTempHpSuccess | DomainError {
|
||||
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
|
||||
|
||||
if (targetIdx === -1) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "combatant-not-found",
|
||||
message: `No combatant found with ID "${combatantId}"`,
|
||||
};
|
||||
}
|
||||
|
||||
const target = encounter.combatants[targetIdx];
|
||||
|
||||
if (target.maxHp === undefined || target.currentHp === undefined) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "no-hp-tracking",
|
||||
message: `Combatant "${combatantId}" does not have HP tracking enabled`,
|
||||
};
|
||||
}
|
||||
|
||||
if (tempHp !== undefined && (!Number.isInteger(tempHp) || tempHp < 1)) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-temp-hp",
|
||||
message: `Temp HP must be a positive integer, got ${tempHp}`,
|
||||
};
|
||||
}
|
||||
|
||||
const previousTempHp = target.tempHp;
|
||||
|
||||
// Higher value wins when both are defined
|
||||
let newTempHp: number | undefined;
|
||||
if (tempHp === undefined) {
|
||||
newTempHp = undefined;
|
||||
} else if (previousTempHp === undefined) {
|
||||
newTempHp = tempHp;
|
||||
} else {
|
||||
newTempHp = Math.max(previousTempHp, tempHp);
|
||||
}
|
||||
|
||||
return {
|
||||
encounter: {
|
||||
combatants: encounter.combatants.map((c) =>
|
||||
c.id === combatantId ? { ...c, tempHp: newTempHp } : c,
|
||||
),
|
||||
activeIndex: encounter.activeIndex,
|
||||
roundNumber: encounter.roundNumber,
|
||||
},
|
||||
events: [
|
||||
{
|
||||
type: "TempHpSet",
|
||||
combatantId,
|
||||
previousTempHp,
|
||||
newTempHp,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export interface Combatant {
|
||||
readonly initiative?: number;
|
||||
readonly maxHp?: number;
|
||||
readonly currentHp?: number;
|
||||
readonly tempHp?: number;
|
||||
readonly ac?: number;
|
||||
readonly conditions?: readonly ConditionId[];
|
||||
readonly isConcentrating?: boolean;
|
||||
|
||||
2255
pnpm-lock.yaml
generated
2255
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
47
scripts/check-cn-classnames.mjs
Normal file
47
scripts/check-cn-classnames.mjs
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Ban template-literal classNames in TSX files.
|
||||
*
|
||||
* Tailwind v4's production content extractor does static analysis on source
|
||||
* files to discover utility classes. Template literals like
|
||||
* className={`foo ${bar}`}
|
||||
* can cause the extractor to miss classes adjacent to `${`, leading to
|
||||
* styles that work in dev (JIT) but break in production.
|
||||
*
|
||||
* Rule: always use cn() for dynamic class composition instead.
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const PATTERN = /className\s*=\s*\{`/;
|
||||
|
||||
function findFiles() {
|
||||
return execSync("git ls-files -- '*.tsx'", { encoding: "utf-8" })
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
let errors = 0;
|
||||
|
||||
for (const file of findFiles()) {
|
||||
const lines = readFileSync(file, "utf-8").split("\n");
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (PATTERN.test(lines[i])) {
|
||||
console.error(
|
||||
`${file}:${i + 1}: className uses template literal — use cn() instead`,
|
||||
);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors > 0) {
|
||||
console.error(
|
||||
`\n${errors} template-literal className(s) found. Use cn() for dynamic classes.`,
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log("No template-literal classNames found.");
|
||||
}
|
||||
99
scripts/check-component-props.mjs
Normal file
99
scripts/check-component-props.mjs
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Enforce a maximum number of explicitly declared props per component
|
||||
* interface.
|
||||
*
|
||||
* Components should consume shared application state via React context
|
||||
* providers, not prop drilling. Props are reserved for per-instance
|
||||
* configuration (a specific data item, a layout variant, a ref).
|
||||
*
|
||||
* Only scans component files (not hooks, adapters, etc.) and only
|
||||
* counts properties declared directly in *Props interfaces — inherited
|
||||
* or extended HTML attributes are not counted.
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { relative } from "node:path";
|
||||
|
||||
const MAX_PROPS = 8;
|
||||
|
||||
const files = execSync(
|
||||
"git ls-files -- 'apps/web/src/components/*.tsx' 'apps/web/src/components/**/*.tsx'",
|
||||
{ encoding: "utf-8" },
|
||||
)
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
|
||||
let errors = 0;
|
||||
|
||||
const propsRegex = /^(?:export\s+)?interface\s+(\w+Props)\s*\{/;
|
||||
|
||||
for (const file of files) {
|
||||
const content = readFileSync(file, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
let inInterface = false;
|
||||
let interfaceName = "";
|
||||
let braceDepth = 0;
|
||||
let parenDepth = 0;
|
||||
let propCount = 0;
|
||||
let startLine = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (!inInterface) {
|
||||
const match = propsRegex.exec(line);
|
||||
if (match) {
|
||||
inInterface = true;
|
||||
interfaceName = match[1];
|
||||
braceDepth = 0;
|
||||
parenDepth = 0;
|
||||
propCount = 0;
|
||||
startLine = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (inInterface) {
|
||||
for (const ch of line) {
|
||||
if (ch === "{") braceDepth++;
|
||||
if (ch === "}") braceDepth--;
|
||||
if (ch === "(") parenDepth++;
|
||||
if (ch === ")") parenDepth--;
|
||||
}
|
||||
|
||||
// Count prop lines at brace depth 1 and not inside function params:
|
||||
// Matches " propName?: type" and " readonly propName: type"
|
||||
if (
|
||||
braceDepth === 1 &&
|
||||
parenDepth === 0 &&
|
||||
/^\s+(?:readonly\s+)?\w+\??\s*:/.test(line)
|
||||
) {
|
||||
propCount++;
|
||||
}
|
||||
|
||||
if (braceDepth === 0) {
|
||||
if (propCount > MAX_PROPS) {
|
||||
const rel = relative(process.cwd(), file);
|
||||
console.error(
|
||||
`${rel}:${startLine}: ${interfaceName} has ${propCount} props (max ${MAX_PROPS})`,
|
||||
);
|
||||
errors++;
|
||||
}
|
||||
inInterface = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors > 0) {
|
||||
console.error(
|
||||
`\n${errors} component(s) exceed the ${MAX_PROPS}-prop limit. Use React context to reduce props.`,
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(
|
||||
`check-component-props: all component interfaces within ${MAX_PROPS}-prop limit`,
|
||||
);
|
||||
}
|
||||
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)**
|
||||
|
||||
A user wants to rename a combatant. Single-clicking the name opens the stat block panel instead of entering edit mode. To rename, the user double-clicks the name or long-presses on touch devices. A `cursor-text` cursor on hover signals that the name is editable.
|
||||
A user wants to rename a combatant. Clicking the combatant's name immediately enters inline edit mode — no delay, no timer, consistent for all combatant types. A `cursor-text` cursor on hover signals that the name is editable. Stat block access is handled separately via a dedicated book icon (see `specs/004-bestiary/spec.md`, FR-062).
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a combatant row is visible, **When** the user single-clicks the combatant name, **Then** the stat block panel opens or toggles — inline edit mode is NOT entered.
|
||||
1. **Given** a combatant row is visible, **When** the user clicks the combatant name, **Then** inline edit mode is entered immediately for that combatant's name — no delay or timer.
|
||||
|
||||
2. **Given** a combatant row is visible, **When** the user double-clicks the combatant name, **Then** inline edit mode is entered for that combatant's name.
|
||||
2. **Given** a combatant row is visible on a pointer device, **When** the user hovers over the combatant name, **Then** the cursor changes to a text cursor (`cursor-text`) to signal editability.
|
||||
|
||||
3. **Given** a combatant row is visible on a pointer device, **When** the user hovers over the combatant name, **Then** the cursor changes to a text cursor (`cursor-text`) to signal editability.
|
||||
3. **Given** inline edit mode has been entered, **When** the user types a new name and presses Enter or blurs the field, **Then** the name is committed. **When** the user presses Escape, **Then** the edit is cancelled and the original name is restored.
|
||||
|
||||
4. **Given** a combatant row is visible on a touch device, **When** the user long-presses the combatant name, **Then** inline edit mode is entered for that combatant's name.
|
||||
|
||||
7. **Given** inline edit mode has been entered (via any trigger), **When** the user types a new name and presses Enter or blurs the field, **Then** the name is committed. **When** the user presses Escape, **Then** the edit is cancelled and the original name is restored.
|
||||
4. **Given** a bestiary combatant row and a custom combatant row, **When** the user clicks either combatant's name, **Then** the behavior is identical — inline edit mode is entered immediately in both cases.
|
||||
|
||||
---
|
||||
|
||||
@@ -291,7 +289,7 @@ EditCombatant MUST return an `"invalid-name"` error when the new name is empty o
|
||||
EditCombatant MUST preserve the combatant's position in the list, `activeIndex`, and `roundNumber`. Setting a name to the same value it already has is treated as a valid update; a `CombatantUpdated` event is still emitted.
|
||||
|
||||
#### FR-024 — Edit: UI
|
||||
The UI MUST provide an inline name-edit mechanism for each combatant, activated by double-clicking the name or long-pressing on touch devices. The name MUST display a `cursor-text` cursor on hover to signal editability. Single-clicking the name MUST open/toggle the stat block panel, not enter edit mode. The updated name MUST be immediately visible after submission.
|
||||
The UI MUST provide an inline name-edit mechanism for each combatant, activated by a single click on the name. Clicking the name MUST enter inline edit mode immediately — no delay, no timer, consistent for all combatant types. The name MUST display a `cursor-text` cursor on hover to signal editability. The updated name MUST be immediately visible after submission. The 250ms click timer and double-click detection logic MUST be removed entirely.
|
||||
|
||||
#### FR-025 — ConfirmButton: Reusable component
|
||||
The system MUST provide a reusable `ConfirmButton` component that wraps any icon button to add a two-step confirmation flow.
|
||||
@@ -364,9 +362,7 @@ All domain events MUST be returned as plain data values from operations, not dis
|
||||
- **ConfirmButton: component unmounts in confirm state**: The auto-revert timer MUST be cleaned up to prevent memory leaks or stale state updates.
|
||||
- **ConfirmButton: two instances in confirm state simultaneously**: Each manages its own state independently.
|
||||
- **ConfirmButton: combatant row re-renders while in confirm state**: Confirm state persists through re-renders as long as combatant identity is stable.
|
||||
- **Name single-click vs double-click**: A single click on the combatant name opens the stat block panel; only a completed double-click enters inline edit mode. The system must disambiguate between the two gestures.
|
||||
- **Touch edit affordance**: No hover-dependent affordance is shown on touch devices. Long-press is the touch equivalent for entering edit mode.
|
||||
- **Long-press threshold**: The long-press duration should follow platform conventions (typically ~500ms). A short tap must not trigger edit mode.
|
||||
- **Name click behavior is uniform**: A single click on any combatant's name enters inline edit mode immediately. There is no gesture disambiguation (no timer, no double-click detection). Stat block access is handled via the dedicated book icon on bestiary rows (see `specs/004-bestiary/spec.md`, FR-062).
|
||||
|
||||
---
|
||||
|
||||
@@ -401,4 +397,4 @@ All domain events MUST be returned as plain data values from operations, not dis
|
||||
- Cross-tab synchronization is not required for the MVP baseline.
|
||||
- The `ConfirmButton` 5-second timeout is a fixed value and is not configurable in the MVP baseline.
|
||||
- The `Check` icon from the Lucide icon library is used for the `ConfirmButton` confirm state.
|
||||
- The inline name-edit mechanism is activated by double-click or long-press (touch). A `cursor-text` cursor on hover signals editability. Single-clicking the name opens the stat block panel.
|
||||
- The inline name-edit mechanism is activated by a single click on the name. A `cursor-text` cursor on hover signals editability. There is no double-click or long-press gesture; stat block access uses a dedicated book icon on bestiary rows.
|
||||
|
||||
@@ -21,6 +21,7 @@ interface Combatant {
|
||||
readonly initiative?: number; // integer, undefined = unset
|
||||
readonly maxHp?: number; // positive integer
|
||||
readonly currentHp?: number; // 0..maxHp
|
||||
readonly tempHp?: number; // positive integer, damage buffer
|
||||
readonly ac?: number; // non-negative integer
|
||||
readonly conditions?: readonly ConditionId[];
|
||||
readonly isConcentrating?: boolean;
|
||||
@@ -96,6 +97,19 @@ As a game master, I want HP values to survive page reloads so that I do not lose
|
||||
Acceptance scenarios:
|
||||
1. **Given** a combatant has max HP 30 and current HP 18, **When** the page is reloaded, **Then** both values are restored exactly.
|
||||
|
||||
**Story HP-7 — Temporary Hit Points (P1)**
|
||||
As a game master, I want to grant temporary HP to a combatant so that I can track damage buffers from spells like Heroism or False Life without manual bookkeeping.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** a combatant has 15/20 HP and no temp HP, **When** the user sets 8 temp HP via the popover, **Then** the combatant displays `15+8 / 20`.
|
||||
2. **Given** a combatant has 15+8/20 HP, **When** 5 damage is dealt, **Then** temp HP decreases to 3 and current HP remains 15 → display `15+3 / 20`.
|
||||
3. **Given** a combatant has 15+3/20 HP, **When** 10 damage is dealt, **Then** temp HP is fully consumed (3 absorbed) and current HP decreases by the remaining 7 → display `8 / 20`.
|
||||
4. **Given** a combatant has 15+5/20 HP, **When** 8 healing is applied, **Then** current HP increases to 20 and temp HP remains 5 → display `20+5 / 20`.
|
||||
5. **Given** a combatant has 10+5/20 HP, **When** the user sets 3 temp HP, **Then** temp HP remains 5 (higher value kept).
|
||||
6. **Given** a combatant has 10+3/20 HP, **When** the user sets 7 temp HP, **Then** temp HP becomes 7.
|
||||
7. **Given** no combatant has temp HP, **When** viewing the encounter, **Then** no extra space is reserved for temp HP display.
|
||||
8. **Given** one combatant has temp HP, **When** viewing the encounter, **Then** all rows reserve space for the temp HP display to maintain column alignment.
|
||||
|
||||
### Requirements
|
||||
|
||||
- **FR-001**: Each combatant MAY have an optional `maxHp` value (positive integer >= 1). HP tracking is optional per combatant.
|
||||
@@ -120,6 +134,15 @@ Acceptance scenarios:
|
||||
- **FR-020**: The HP area MUST display the unconscious color treatment (red) and the combatant row MUST appear visually muted when status is `unconscious`.
|
||||
- **FR-021**: Status indicators MUST NOT be shown when `maxHp` is not set.
|
||||
- **FR-022**: Visual status indicators MUST update within the same interaction frame as the HP change — no perceptible delay.
|
||||
- **FR-023**: Each combatant MAY have an optional `tempHp` value (positive integer >= 1). Temp HP is independent of regular HP tracking but requires HP tracking to be enabled.
|
||||
- **FR-024**: When damage is applied, temp HP MUST absorb damage first. Any remaining damage after temp HP is depleted MUST reduce `currentHp`.
|
||||
- **FR-025**: Healing MUST NOT restore temp HP. Healing applies only to `currentHp`.
|
||||
- **FR-026**: When setting temp HP on a combatant that already has temp HP, the system MUST keep the higher of the two values.
|
||||
- **FR-027**: When `maxHp` is cleared (HP tracking disabled), `tempHp` MUST also be cleared.
|
||||
- **FR-028**: The temp HP value MUST be displayed as a cyan `+N` immediately after the current HP value, only when temp HP > 0.
|
||||
- **FR-029**: When any combatant in the encounter has temp HP > 0, all rows MUST reserve space for the temp HP display to maintain column alignment. When no combatant has temp HP, no space is reserved.
|
||||
- **FR-030**: The HP adjustment popover MUST include a third button (Shield icon) for setting temp HP.
|
||||
- **FR-031**: Temp HP MUST persist across page reloads via the existing persistence mechanism.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
@@ -131,7 +154,10 @@ Acceptance scenarios:
|
||||
- Submitting an empty delta input applies no change; the input remains ready.
|
||||
- When the user rapidly applies multiple deltas, each is applied sequentially; none are lost.
|
||||
- HP tracking is entirely absent for combatants with no `maxHp` set — no HP controls are shown.
|
||||
- There is no temporary HP in the MVP baseline.
|
||||
- Setting temp HP to 0 or clearing it removes temp HP entirely.
|
||||
- Temp HP does not affect `HpStatus` derivation — a combatant with 5 current HP, 5 temp HP, and 20 max HP is still bloodied.
|
||||
- When a concentrating combatant takes damage, the concentration pulse MUST trigger regardless of whether temp HP absorbs the damage — "taking damage" is the trigger, not losing real HP.
|
||||
- A combatant at 0 currentHp with temp HP remaining is still unconscious.
|
||||
- There is no death/unconscious game mechanic triggered at 0 HP; the system displays the state only.
|
||||
- There is no damage type tracking, resistance/vulnerability calculation, or hit log in the MVP baseline.
|
||||
- There is no undo/redo for HP changes in the MVP baseline.
|
||||
@@ -203,12 +229,13 @@ Acceptance scenarios:
|
||||
2. **Given** the condition picker is open, **When** the user clicks an active condition, **Then** it is toggled off and removed from the row.
|
||||
3. **Given** a combatant with one condition and it is removed, **Then** only the hover-revealed "+" button remains.
|
||||
|
||||
**Story CC-3 — View Condition Name via Tooltip (P2)**
|
||||
As a DM, I want to hover over a condition icon to see its name so I can identify conditions without memorizing icons.
|
||||
**Story CC-3 — View Condition Details via Tooltip (P2)**
|
||||
As a DM, I want to hover over a condition icon to see its name and rules description so I can quickly reference the condition's effects during play.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** a combatant has an active condition, **When** the user hovers over its icon, **Then** a tooltip shows the condition name (e.g., "Blinded").
|
||||
1. **Given** a combatant has an active condition, **When** the user hovers over its icon, **Then** a tooltip shows the condition name and its rules description matching the selected edition.
|
||||
2. **Given** the user moves the cursor away from the icon, **Then** the tooltip disappears.
|
||||
3. **Given** the rules edition is set to 5e (2014), **When** the user hovers over "Exhaustion", **Then** the tooltip shows the 2014 exhaustion rules (6-level escalating table). **When** the edition is 5.5e (2024), **Then** the tooltip shows the 2024 rules (−2 per level to d20 tests, −5 ft speed per level).
|
||||
|
||||
**Story CC-4 — Multiple Conditions (P2)**
|
||||
As a DM, I want to apply multiple conditions to a single combatant so I can track complex combat situations.
|
||||
@@ -246,9 +273,21 @@ Acceptance scenarios:
|
||||
3. **Given** a combatant is NOT concentrating, **When** damage is taken, **Then** no pulse/flash occurs.
|
||||
4. **Given** a concentrating combatant takes damage, **When** the animation completes, **Then** the row returns to its normal concentration-active appearance.
|
||||
|
||||
**Story CC-8 — Rules Edition Setting (P2)**
|
||||
As a DM who plays in both 5e (2014) and 5.5e (2024) groups, I want to choose which edition's condition descriptions appear in tooltips so I reference the correct rules for the game I am running.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** the user opens the kebab menu, **When** they click "Settings", **Then** a settings modal opens.
|
||||
2. **Given** the settings modal is open, **When** viewing the Conditions section, **Then** a rules edition selector shows 5e (2014) and 5.5e (2024) with 5.5e selected by default.
|
||||
3. **Given** the user selects 5e (2014), **When** hovering a condition icon (e.g., Exhaustion), **Then** the tooltip shows the 2014 description.
|
||||
4. **Given** the user selects 5.5e (2024), **When** hovering the same condition, **Then** the tooltip shows the 2024 description.
|
||||
5. **Given** the user changes the edition and reloads the page, **Then** the selected edition is preserved.
|
||||
6. **Given** a condition with identical rules across editions (e.g., Deafened), **Then** the tooltip text is the same regardless of setting.
|
||||
7. **Given** the settings modal is open, **When** viewing the Theme section, **Then** a System / Light / Dark selector is available, replacing the inline cycle button in the kebab menu.
|
||||
|
||||
### Requirements
|
||||
|
||||
- **FR-032**: The MVP MUST support the following 15 standard D&D 5e conditions: blinded, charmed, deafened, exhaustion, frightened, grappled, incapacitated, invisible, paralyzed, petrified, poisoned, prone, restrained, stunned, unconscious.
|
||||
- **FR-032**: The MVP MUST support the following 15 standard D&D 5e/5.5e conditions: blinded, charmed, deafened, exhaustion, frightened, grappled, incapacitated, invisible, paralyzed, petrified, poisoned, prone, restrained, stunned, unconscious.
|
||||
- **FR-033**: Each condition MUST have a fixed icon and color mapping (Lucide icons; no emoji):
|
||||
|
||||
| Condition | Icon | Color |
|
||||
@@ -275,7 +314,7 @@ Acceptance scenarios:
|
||||
- **FR-037**: Clicking "+" MUST open the compact condition picker showing all conditions as icon + label pairs.
|
||||
- **FR-038**: Clicking a condition in the picker MUST toggle it on or off.
|
||||
- **FR-039**: Clicking an active condition icon tag in the row MUST remove that condition.
|
||||
- **FR-040**: Hovering an active condition icon MUST show a tooltip with the condition name.
|
||||
- **FR-040**: Hovering an active condition icon MUST show a tooltip with the condition name and its rules description for the selected edition.
|
||||
- **FR-041**: Condition icons MUST NOT increase the row's width; row height MAY increase to accommodate wrapping.
|
||||
- **FR-042**: The condition picker MUST close when the user clicks outside of it.
|
||||
- **FR-043**: Conditions MUST persist as part of combatant state (surviving page reload).
|
||||
@@ -301,6 +340,9 @@ Acceptance scenarios:
|
||||
- When concentration is toggled during an active pulse animation, the animation cancels and the new state applies immediately.
|
||||
- Multiple combatants may concentrate simultaneously; concentration is independent per combatant.
|
||||
- Conditions have no mechanical effects in the MVP baseline (no auto-disadvantage, no automation).
|
||||
- When the rules edition preference is missing from localStorage, the system defaults to 5.5e (2024).
|
||||
- Changing the edition while a condition tooltip is visible updates the tooltip on next hover (no live update required).
|
||||
- The settings modal is app-level UI; it does not interact with encounter state.
|
||||
|
||||
---
|
||||
|
||||
@@ -419,16 +461,15 @@ Acceptance scenarios:
|
||||
3. **Given** any combatant row, **When** hovered and concentration is inactive, **Then** the Brain icon becomes visible.
|
||||
4. **Given** the remove button appears on hover, **Then** no layout shift occurs — space is reserved.
|
||||
|
||||
**Story ROW-3 — Row Click Opens Stat Block (P1)**
|
||||
As a DM, I want to click anywhere on a bestiary combatant row to open its stat block so I have a large click target and a cleaner row without a dedicated book icon.
|
||||
**Story ROW-3 — Book Icon Opens Stat Block (P1)**
|
||||
As a DM, I want a dedicated book icon on bestiary combatant rows so I can open the stat block with an explicit, discoverable control — while clicking the name always starts a rename.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** a combatant has a linked bestiary creature, **When** the user clicks the name text or empty row space, **Then** the stat block panel opens.
|
||||
2. **Given** the user clicks an interactive element (initiative, HP, AC, condition icon, "+", "x", concentration), **Then** the stat block does NOT open — the element's own action fires.
|
||||
3. **Given** a combatant does NOT have a linked creature, **When** the row is clicked, **Then** nothing happens.
|
||||
4. **Given** viewing any bestiary combatant row, **Then** no BookOpen icon is visible.
|
||||
5. **Given** a bestiary combatant row, **When** the user hovers over non-interactive areas, **Then** the cursor indicates clickability.
|
||||
6. **Given** the stat block is already open for a creature, **When** the same row is clicked, **Then** the panel closes (toggle behavior).
|
||||
1. **Given** a combatant has a linked bestiary creature, **When** the user views the row, **Then** a small BookOpen icon is visible next to the name.
|
||||
2. **Given** a combatant does NOT have a linked creature, **When** the user views the row, **Then** no BookOpen icon is displayed.
|
||||
3. **Given** a bestiary combatant row, **When** the user clicks the BookOpen icon, **Then** the stat block panel opens for that creature.
|
||||
4. **Given** the user clicks the combatant's name, **Then** inline rename mode is entered — the stat block does NOT open.
|
||||
5. **Given** the stat block is already open for a creature, **When** the user clicks its BookOpen icon again, **Then** the panel closes (toggle behavior).
|
||||
|
||||
### Requirements
|
||||
|
||||
@@ -436,10 +477,10 @@ Acceptance scenarios:
|
||||
- **FR-081**: The "+" condition button MUST be hidden at rest and appear on row hover (or touch/focus on touch devices).
|
||||
- **FR-082**: The remove (x) button MUST be hidden at rest and appear on row hover (or touch/focus on touch devices).
|
||||
- **FR-083**: Layout space for the remove button MUST be reserved so appearing/disappearing does not cause layout shifts.
|
||||
- **FR-084**: Clicking non-interactive areas of a bestiary combatant row MUST open the stat block panel.
|
||||
- **FR-085**: Clicking interactive elements (initiative, HP, AC, conditions, "+", "x", concentration) MUST NOT trigger the stat block — only the element's own action.
|
||||
- **FR-086**: The BookOpen icon MUST be removed from the combatant row.
|
||||
- **FR-087**: Bestiary combatant rows MUST show a pointer cursor on hover over non-interactive areas.
|
||||
- **FR-084**: Bestiary-linked combatant rows MUST display a BookOpen icon as the dedicated stat block trigger (see also `specs/004-bestiary/spec.md`, FR-062).
|
||||
- **FR-085**: Clicking the combatant name MUST enter inline rename mode, not open the stat block.
|
||||
- **FR-086**: Non-bestiary combatant rows MUST NOT display the BookOpen icon.
|
||||
- **FR-087**: The BookOpen icon MUST have a tooltip ("View stat block") and `aria-label="View stat block"` for accessibility.
|
||||
- **FR-088**: All existing interactions (condition add/remove, HP adjustment, AC editing, initiative editing/rolling, concentration toggle, combatant removal) MUST continue to work.
|
||||
- **FR-089**: Browser scrollbars MUST be styled to match the dark UI theme (thin, dark-colored scrollbar thumbs).
|
||||
- **FR-090**: Turn navigation (Previous/Next) MUST use StepBack/StepForward icons in outline button style with foreground-colored borders. Utility actions (d20/trash) MUST use ghost button style to create clear visual hierarchy.
|
||||
@@ -447,13 +488,21 @@ Acceptance scenarios:
|
||||
- **FR-092**: The "Initiative Tracker" heading MUST be removed to maximize vertical space for combatants.
|
||||
- **FR-093**: All interactive elements in the row MUST remain keyboard-accessible (focusable and operable via keyboard).
|
||||
- **FR-094**: On touch devices, hover-only controls ("+" button, "x" button) MUST remain accessible via tap or focus.
|
||||
- **FR-095**: The system MUST provide a settings modal accessible via a "Settings" item in the kebab overflow menu.
|
||||
- **FR-096**: The settings modal MUST include a Conditions section with a rules edition selector offering two options: 5e (2014) and 5.5e (2024).
|
||||
- **FR-097**: The default rules edition MUST be 5.5e (2024).
|
||||
- **FR-098**: Each condition definition MUST carry a description for both editions. Conditions with identical rules across editions MAY share a single description value.
|
||||
- **FR-099**: Condition tooltips MUST display the description corresponding to the active rules edition preference.
|
||||
- **FR-100**: The rules edition preference MUST persist across sessions via localStorage (key `"initiative:rules-edition"`).
|
||||
- **FR-101**: The settings modal MUST include a Theme section with System / Light / Dark options, replacing the inline theme cycle button in the kebab menu.
|
||||
- **FR-102**: The settings modal MUST close on Escape, click-outside, or the close button.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- When a combatant has so many conditions that they exceed the available inline space, they wrap within the name column; row height increases but width does not.
|
||||
- The condition picker dropdown positions relative to the "+" button, flipping vertically if near the viewport edge.
|
||||
- When the stat block panel is already open and the user clicks the same row again, the panel closes.
|
||||
- Clicking the initiative area starts editing; it does not open the stat block.
|
||||
- When the stat block panel is already open and the user clicks the same BookOpen icon again, the panel closes.
|
||||
- Clicking the combatant name starts inline rename; it does not open the stat block.
|
||||
- Tablet-width screens (>= 768 px / Tailwind `md`): popovers and inline edits MUST remain accessible and not overflow or clip.
|
||||
|
||||
---
|
||||
@@ -490,3 +539,6 @@ Acceptance scenarios:
|
||||
- **SC-028**: The AC number is visually identifiable as armor class through the shield shape alone.
|
||||
- **SC-029**: No layout shift occurs when hovering/unhovering rows.
|
||||
- **SC-030**: All HP, AC, initiative, condition, and concentration interactions remain fully operable using only a keyboard.
|
||||
- **SC-031**: The user can switch rules edition in 2 interactions (open settings → select edition).
|
||||
- **SC-032**: Condition tooltips accurately reflect the selected edition's rules text for all conditions that differ between editions.
|
||||
- **SC-033**: The rules edition preference survives a full page reload.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The Bestiary feature provides creature search across a pre-indexed library of 3,312+ creatures from 102+ D&D sources, stat block display for full creature reference during combat, source data management via on-demand fetch or file upload, and a dual-panel UX with fold/unfold and pin capabilities. Creatures can be added individually or in batch from a search dropdown, with stats pre-filled from the index.
|
||||
The Bestiary feature provides creature search across a pre-indexed library of 3,312+ creatures from 102+ D&D sources, stat block display for full creature reference during combat, source data management via on-demand fetch or file upload, and a dual-panel UX with collapse/expand and pin capabilities. Creatures can be added individually or in batch from a search dropdown, with stats pre-filled from the index.
|
||||
|
||||
The architecture uses a two-tier design: a lightweight search index (`data/bestiary/index.json`) shipped with the app containing mechanical facts for all creatures, and full stat block data loaded on-demand at the source level, normalized, and cached in IndexedDB.
|
||||
|
||||
@@ -84,7 +84,7 @@ When the search input has no bestiary matches (or fewer than 2 characters typed)
|
||||
**US-D1 — View Full Stat Block in Side Panel (P2)**
|
||||
As a DM, I want to see the full stat block of a creature displayed in a side panel so that I can reference its abilities, actions, and traits during combat without switching to another tool.
|
||||
|
||||
When a creature is selected from search results or when clicking a bestiary-linked combatant in the tracker, a stat block panel appears showing the creature's full information in the classic D&D stat block layout. Clicking a different combatant updates the panel to that creature's data.
|
||||
When a creature is selected from search results or when clicking the book icon on a bestiary-linked combatant row, a stat block panel appears showing the creature's full information in the classic D&D stat block layout. Clicking the book icon on a different combatant updates the panel to that creature's data. The book icon is the only way to manually open a stat block from the tracker — clicking the combatant's name always enters inline rename mode (see `specs/001-combatant-management/spec.md`, FR-024).
|
||||
|
||||
**US-D2 — Preview Stat Block from Search Dropdown (P3)**
|
||||
As a DM, I want to preview a creature's stat block directly from the search dropdown so I can review creature details before deciding to add them to the encounter.
|
||||
@@ -103,18 +103,22 @@ As a DM using the app on different devices, I want the layout to adapt between s
|
||||
- **FR-020**: On wide viewports (desktop), the layout MUST be side-by-side with the encounter tracker on the left and stat block on the right.
|
||||
- **FR-021**: On narrow viewports (mobile), the stat block MUST appear as a dismissible drawer or slide-over.
|
||||
- **FR-022**: The stat block panel MUST scroll independently of the encounter tracker.
|
||||
- **FR-023**: When the user clicks a different bestiary-linked combatant in the tracker, the stat block panel MUST update to show that creature's data.
|
||||
- **FR-023**: When the user clicks the book icon on a different bestiary-linked combatant row, the stat block panel MUST update to show that creature's data.
|
||||
- **FR-024**: The existing search/view button MUST be repurposed to open the stat block for the currently focused/selected creature in the dropdown.
|
||||
- **FR-062**: Bestiary-linked combatant rows MUST display a small book icon (Lucide `BookOpen`) as the dedicated stat block trigger. The icon MUST have a tooltip ("View stat block") and an `aria-label="View stat block"` for accessibility. The book icon is the only way to manually open a stat block from the tracker — clicking the combatant name always enters inline rename mode. Non-bestiary combatant rows MUST NOT display the book icon.
|
||||
|
||||
### Acceptance Scenarios
|
||||
|
||||
1. **Given** a creature is selected from the bestiary search, **When** the stat block panel opens, **Then** it displays: name, size, type, alignment, AC, HP (average and formula), speed, ability scores with modifiers, saving throws, skills, damage resistances/immunities, condition immunities, senses, languages, challenge rating, traits, actions, and legendary actions (if applicable).
|
||||
2. **Given** the stat block panel is open on desktop (wide viewport), **Then** the layout is side-by-side: encounter tracker on the left, stat block panel on the right.
|
||||
3. **Given** the stat block panel is open on mobile (narrow viewport), **Then** the stat block appears as a slide-over drawer that can be dismissed.
|
||||
4. **Given** a stat block is displayed, **When** the user clicks a different bestiary-linked combatant in the tracker, **Then** the stat block panel updates to show that creature's data.
|
||||
4. **Given** a stat block is displayed, **When** the user clicks the book icon on a different bestiary-linked combatant row, **Then** the stat block panel updates to show that creature's data.
|
||||
5. **Given** a creature entry contains markup tags (e.g., spell references, dice notation), **Then** they render as plain text.
|
||||
6. **Given** the dropdown is showing bestiary results, **When** the user clicks the stat block view button, **Then** the stat block panel opens for the currently focused/highlighted creature in the dropdown.
|
||||
7. **Given** no creature is focused in the dropdown, **When** the user clicks the stat block view button, **Then** nothing happens (button is disabled or no-op).
|
||||
8. **Given** a bestiary-linked combatant row is visible, **When** the user looks at the row, **Then** a small book icon is visible as the stat block trigger with a tooltip "View stat block".
|
||||
9. **Given** a custom (non-bestiary) combatant row is visible, **When** the user looks at the row, **Then** no book icon is displayed.
|
||||
10. **Given** a bestiary-linked combatant row is visible, **When** the user clicks the combatant's name, **Then** inline rename mode is entered — the stat block does NOT open.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
@@ -144,7 +148,7 @@ While the bulk import is in progress, the user sees a text counter ("Loading sou
|
||||
If the user closes the side panel while a bulk import is still in progress, a persistent toast notification appears at the bottom-center of the screen showing the same progress text and progress bar.
|
||||
|
||||
**US-M6 — Manage Cached Sources (P4)**
|
||||
A DM wants to see which sources are cached, clear a specific source's cache, or clear all cached data. A management UI provides this visibility and control.
|
||||
A DM wants to see which sources are cached, find a specific source, clear a specific source's cache, or clear all cached data. A management UI provides this visibility and control, including a filter input to quickly locate sources by name when many are cached.
|
||||
|
||||
### Requirements
|
||||
|
||||
@@ -170,7 +174,7 @@ A DM wants to see which sources are cached, clear a specific source's cache, or
|
||||
- **FR-044**: The bulk import MUST run asynchronously and not block the rest of the app.
|
||||
- **FR-045**: The user MUST explicitly provide/confirm the URL before any fetches occur — the app never auto-fetches content.
|
||||
- **FR-046**: The "Load All" button MUST be disabled when the URL field is empty or while a bulk import is already in progress.
|
||||
- **FR-047**: The app MUST provide a management UI showing cached sources with options to clear individual sources or all cached data.
|
||||
- **FR-047**: The app MUST provide a management UI showing cached sources with a filter input for searching by display name and options to clear individual sources or all cached data.
|
||||
- **FR-048**: The normalization adapter and tag-stripping utility MUST remain the canonical pipeline for all fetched and uploaded data.
|
||||
- **FR-049**: The distributed app bundle MUST contain zero copyrighted prose content — only mechanical facts and creature names in the search index.
|
||||
|
||||
@@ -194,6 +198,7 @@ A DM wants to see which sources are cached, clear a specific source's cache, or
|
||||
16. **Given** two sources have been cached, **When** the DM opens the source management UI, **Then** both sources are listed with their display names.
|
||||
17. **Given** the source management UI is open, **When** the DM clears a single source, **Then** that source's data is removed; stat blocks for its creatures require re-fetching, while other cached sources remain.
|
||||
18. **Given** the source management UI is open, **When** the DM clears all cached data, **Then** all source data is removed and all stat blocks require re-fetching.
|
||||
19. **Given** many sources are cached, **When** the DM types a partial name in the filter input, **Then** only sources whose display name matches (case-insensitive) are shown.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
@@ -210,58 +215,58 @@ A DM wants to see which sources are cached, clear a specific source's cache, or
|
||||
|
||||
---
|
||||
|
||||
## Panel UX (Fold, Pin, Second Panel)
|
||||
## Panel UX (Collapse, Pin, Second Panel)
|
||||
|
||||
### User Stories
|
||||
|
||||
**US-P1 — Fold and Unfold Stat Block Panel (P1)**
|
||||
**US-P1 — Collapse and Expand Stat Block Panel (P1)**
|
||||
As a DM running an encounter, I want to collapse the stat block panel to a slim tab so I can temporarily reclaim screen space without losing my place, then quickly expand it again to reference creature stats.
|
||||
|
||||
The close button is replaced with a fold/unfold toggle. Folding slides the panel out to the right edge, leaving a slim vertical tab displaying the creature's name. Clicking the tab unfolds the panel, showing the same creature that was displayed before folding. No "Stat Block" heading text is shown in the panel header.
|
||||
The close button is replaced with a collapse/expand toggle. Collapsing slides the panel out to the right edge, leaving a slim vertical tab displaying the creature's name. Clicking the tab expands the panel, showing the same creature that was displayed before collapsing. No "Stat Block" heading text is shown in the panel header.
|
||||
|
||||
**US-P2 — Pin Creature to Second Panel (P2)**
|
||||
As a DM comparing creatures or referencing one creature while browsing others, I want to pin the current creature to a secondary panel on the left side of the screen so I can keep it visible while browsing different creatures in the right panel.
|
||||
|
||||
Clicking the pin button copies the current creature to a new left panel. The right panel remains active for browsing different creatures independently. The left panel has an unpin button that removes it.
|
||||
|
||||
**US-P3 — Fold Behavior with Pinned Panel (P3)**
|
||||
As a DM with a creature pinned, I want to fold the right (browse) panel independently so I can focus on just the pinned creature, or fold both panels to see the full encounter list.
|
||||
**US-P3 — Collapse Behavior with Pinned Panel (P3)**
|
||||
As a DM with a creature pinned, I want to collapse the right (browse) panel independently so I can focus on just the pinned creature, or collapse both panels to see the full encounter list.
|
||||
|
||||
### Requirements
|
||||
|
||||
- **FR-050**: The system MUST replace the close button on the stat block panel with a fold/unfold toggle control.
|
||||
- **FR-050**: The system MUST replace the close button on the stat block panel with a collapse/expand toggle control.
|
||||
- **FR-051**: The system MUST remove the "Stat Block" heading text from the panel header.
|
||||
- **FR-052**: When folded, the panel MUST collapse to a slim vertical tab anchored to the right edge of the screen displaying the creature's name.
|
||||
- **FR-053**: Folding and unfolding MUST use a smooth CSS slide animation (~200ms ease-out).
|
||||
- **FR-054**: The fold/unfold toggle MUST preserve the currently displayed creature — unfolding shows the same creature that was visible when folded.
|
||||
- **FR-052**: When collapsed, the panel MUST reduce to a slim vertical tab anchored to the right edge of the screen displaying the creature's name.
|
||||
- **FR-053**: Collapsing and expanding MUST use a smooth CSS slide animation (~200ms ease-out).
|
||||
- **FR-054**: The collapse/expand toggle MUST preserve the currently displayed creature — expanding shows the same creature that was visible when collapsed.
|
||||
- **FR-055**: The panel MUST include a pin button that copies the current creature to a new panel on the left side of the screen.
|
||||
- **FR-056**: After pinning, the right panel MUST remain active for browsing different creatures independently.
|
||||
- **FR-057**: The pinned (left) panel MUST include an unpin button that removes it when clicked.
|
||||
- **FR-058**: The pin button MUST be hidden on viewports where dual panels would not fit (small screens / mobile).
|
||||
- **FR-059**: The pin button MUST be hidden when the panel is showing a source fetch prompt (no creature data displayed yet).
|
||||
- **FR-060**: On mobile viewports, the existing drawer/backdrop behavior MUST be preserved — fold/unfold replaces only the close button behavior for the desktop layout; the backdrop click still dismisses the panel.
|
||||
- **FR-061**: Both the browse (right) and pinned (left) panels MUST have independent fold states.
|
||||
- **FR-060**: On mobile viewports, the existing drawer/backdrop behavior MUST be preserved — collapse/expand replaces only the close button behavior for the desktop layout; the backdrop click still dismisses the panel.
|
||||
- **FR-061**: Both the browse (right) and pinned (left) panels MUST have independent collapsed states.
|
||||
|
||||
### Acceptance Scenarios
|
||||
|
||||
1. **Given** the stat block panel is open showing a creature, **When** the user clicks the fold button, **Then** the panel slides out to the right edge and a slim vertical tab appears showing the creature's name.
|
||||
2. **Given** the stat block panel is folded to a tab, **When** the user clicks the tab, **Then** the panel slides back in showing the same creature that was displayed before folding.
|
||||
3. **Given** the stat block panel is open, **When** the user looks for a close button, **Then** no close button is present — only a fold toggle.
|
||||
1. **Given** the stat block panel is open showing a creature, **When** the user clicks the collapse button, **Then** the panel slides out to the right edge and a slim vertical tab appears showing the creature's name.
|
||||
2. **Given** the stat block panel is collapsed to a tab, **When** the user clicks the tab, **Then** the panel slides back in showing the same creature that was displayed before collapsing.
|
||||
3. **Given** the stat block panel is open, **When** the user looks for a close button, **Then** no close button is present — only a collapse toggle.
|
||||
4. **Given** the stat block panel is open, **When** the user looks at the panel header, **Then** no "Stat Block" heading text is visible.
|
||||
5. **Given** the panel is folding or unfolding, **When** the animation plays, **Then** it completes with a smooth slide transition (~200ms ease-out).
|
||||
5. **Given** the panel is collapsing or expanding, **When** the animation plays, **Then** it completes with a smooth slide transition (~200ms ease-out).
|
||||
6. **Given** the stat block panel is showing a creature on a wide screen, **When** the user clicks the pin button, **Then** the current creature appears in a new panel on the left side of the screen.
|
||||
7. **Given** a creature is pinned to the left panel, **When** the user clicks a different combatant in the encounter list, **Then** the right panel updates to show the new creature while the left panel continues showing the pinned creature.
|
||||
7. **Given** a creature is pinned to the left panel, **When** the user clicks the book icon on a different bestiary combatant, **Then** the right panel updates to show the new creature while the left panel continues showing the pinned creature.
|
||||
8. **Given** a creature is pinned to the left panel, **When** the user clicks the unpin button on the left panel, **Then** the left panel is removed and only the right panel remains.
|
||||
9. **Given** the user is on a small screen or mobile viewport, **When** the stat block panel is displayed, **Then** the pin button is not visible.
|
||||
10. **Given** both pinned (left) and browse (right) panels are open, **When** the user folds the right panel, **Then** the left pinned panel remains visible and the right panel collapses to a tab.
|
||||
11. **Given** the right panel is folded and the left panel is pinned, **When** the user unfolds the right panel, **Then** it slides back showing the last browsed creature.
|
||||
10. **Given** both pinned (left) and browse (right) panels are open, **When** the user collapses the right panel, **Then** the left pinned panel remains visible and the right panel reduces to a tab.
|
||||
11. **Given** the right panel is collapsed and the left panel is pinned, **When** the user expands the right panel, **Then** it slides back showing the last browsed creature.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- User pins a creature and then that creature is removed from the encounter: the pinned panel continues displaying the creature's stat block (data is already loaded).
|
||||
- User folds the panel and then the active combatant changes (auto-show logic on desktop): the panel stays folded but updates the selected creature internally; unfolding shows the current active combatant's stat block. The fold state is respected — advancing turns does not override a user-chosen fold.
|
||||
- Active combatant changes while panel is open: if the new active combatant has a creature, the panel auto-updates to show that creature's stat block. If the new active combatant has no creature, the panel remains on the previous creature. If the panel is collapsed, it stays collapsed. If the panel is closed, it stays closed.
|
||||
- Viewport resized from wide to narrow while a creature is pinned: the pinned (left) panel is hidden and the pin button disappears; resizing back to wide restores the pinned panel.
|
||||
- User is in bulk import mode and tries to fold: the fold/unfold behavior applies to the bulk import panel identically.
|
||||
- User is in bulk import mode and tries to collapse: the collapse/expand behavior applies to the bulk import panel identically.
|
||||
- Panel showing a source fetch prompt: the pin button is hidden.
|
||||
|
||||
---
|
||||
@@ -276,7 +281,7 @@ As a DM with a creature pinned, I want to fold the right (browse) panel independ
|
||||
- **Queued Creature**: Transient UI-only state representing a bestiary creature selected for batch-add, containing the creature reference and a count (1+). Not persisted.
|
||||
- **Bulk Import Operation**: Tracks total sources, completed count, failed count, and current status (idle / loading / complete / partial-failure).
|
||||
- **Toast Notification**: Lightweight custom UI element at bottom-center of screen with text, optional progress bar, and optional dismiss button.
|
||||
- **Panel State**: Represents whether a stat block panel is expanded, folded, or absent. The browse (right) and pinned (left) panels each have independent state.
|
||||
- **Panel State**: Represents whether a stat block panel is expanded, collapsed, or absent. The browse (right) and pinned (left) panels each have independent state.
|
||||
|
||||
---
|
||||
|
||||
@@ -295,7 +300,7 @@ As a DM with a creature pinned, I want to fold the right (browse) panel independ
|
||||
- **SC-011**: Users can load all bestiary sources with a single confirmation action; real-time progress is visible during the operation.
|
||||
- **SC-012**: Already-cached sources are skipped during bulk import, reducing redundant data transfer on repeat imports.
|
||||
- **SC-013**: The rest of the app remains fully interactive during the bulk import operation.
|
||||
- **SC-014**: Users can fold the stat block panel in a single click and unfold it in a single click, with the transition completing in under 300ms.
|
||||
- **SC-014**: Users can collapse the stat block panel in a single click and expand it in a single click, with the transition completing in under 300ms.
|
||||
- **SC-015**: Users can pin a creature and browse a different creature's stat block simultaneously, without any additional navigation steps.
|
||||
- **SC-016**: The pinned panel is never visible on screens narrower than the dual-panel breakpoint, ensuring mobile usability is not degraded.
|
||||
- **SC-017**: All fold/unfold and pin/unpin interactions are discoverable through visible button controls without requiring documentation or tooltips.
|
||||
- **SC-017**: All collapse/expand and pin/unpin interactions are discoverable through visible button controls without requiring documentation or tooltips.
|
||||
|
||||
Reference in New Issue
Block a user