17 Commits

Author SHA1 Message Date
Lukas
75778884bd Hide top bar in empty state and animate it in with first combatant
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 18s
The turn navigation bar is now hidden when no combatants exist, keeping
the empty state clean. It slides down from above when the first
combatant is added, synchronized with the action bar settling animation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:43:41 +01:00
Lukas
72d4f30e60 Center action bar in empty state for better onboarding UX
Replace the abstract + icon with the actual input field centered at the
optical center when no combatants exist. Animate the transition in both
directions: settling down when the first combatant is added, rising up
when all combatants are removed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:29:51 +01:00
Lukas
96b37d4bdd Color player character names instead of left border
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 18s
Player characters now show their chosen color on their name text
rather than as a left border glow. Left border is reserved for
active row (accent) and concentration (purple).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:52:09 +01:00
Lukas
76ca78c169 Improve player modals: Escape to close, trash icon for delete
Both player management and create/edit modals now close on Escape.
Delete player character button uses Trash2 icon instead of X to
distinguish permanent deletion from dismissal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:51:58 +01:00
Lukas
b0c27b8ab9 Add red hover effect to destructive buttons
ConfirmButton now shows hover:text-hover-destructive in its default
state. Source manager delete buttons and Clear All get matching
destructive hover styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:51:34 +01:00
Lukas
458c277e9f Polish UI: consistent icon buttons, tooltips, modal backdrop close, and top bar layout
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 17s
- Standardize icon button sizing (size="icon") and color (text-muted-foreground) across top and bottom bars
- Group bottom bar icon buttons with gap-0 to match top bar style
- Add missing tooltips/aria-labels for stat block viewer, bulk import buttons
- Replace Settings icon with Library for source manager
- Make step forward/back buttons use primary (solid) variant
- Move round badge next to combatant name in center of top bar
- Close player create/edit and management modals on backdrop click

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:47:47 +01:00
Lukas
91703ddebc Add player character management feature
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 18s
Persistent player character templates (name, AC, HP, color, icon) with
full CRUD, bestiary-style search to add PCs to encounters with pre-filled
stats, and color/icon visual distinction in combatant rows. Also stops
the stat block panel from auto-opening when adding a creature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:11:08 +01:00
Lukas
768e7a390f Improve empty encounter UX with interactive add button
All checks were successful
CI / check (push) Successful in 44s
CI / build-image (push) Successful in 22s
Replace the static "No combatants yet" text with a centered, breathing
"+" icon that focuses the action bar input on click, guiding users to
add their first combatant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:56:56 +01:00
Lukas
7feaf90eab Add "custom creature" option to bestiary suggestions dropdown
When typing a name that partially matches bestiary entries, users
couldn't access the custom creature fields (Init/AC/MaxHP). Now a
prominent option at the top of the dropdown lets users dismiss
suggestions and add a custom creature instead, with an Esc hint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:45:39 +01:00
Lukas
b39e4923e1 Remove demo combatants and allow empty encounters
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 28s
Empty encounters are now valid (INV-1 updated). New sessions start
with zero combatants instead of pre-populated Aria/Brak/Cael.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:24:26 +01:00
Lukas
369feb3cc8 Add deploy step to CI workflow to auto-restart container on tag push
All checks were successful
CI / check (push) Successful in 43s
CI / build-image (push) Successful in 17s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:19:34 +01:00
Lukas
51bdb799ae Skip lifecycle scripts in Docker build to avoid missing git
All checks were successful
CI / check (push) Successful in 47s
CI / build-image (push) Successful in 29s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:06:49 +01:00
Lukas
1baddad939 Remove pnpm cache from setup-node to fix Gitea Actions timeout
Some checks failed
CI / check (push) Successful in 47s
CI / build-image (push) Failing after 18s
2026-03-12 09:52:16 +01:00
Lukas
e701e4dd70 Run build-image job on host to access Docker CLI
Some checks failed
CI / check (push) Failing after 14m13s
CI / build-image (push) Has been cancelled
The node:22 container doesn't have Docker installed. Running
on the host label executes directly on the VPS where Docker
is available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:18:51 +01:00
Lukas
e2b0e7d5ee Exclude .pnpm-store from Biome and add .dockerignore
Some checks failed
CI / check (push) Successful in 10m21s
CI / build-image (push) Failing after 4s
The CI runner's pnpm store lands inside the workspace, causing
Biome to lint/format hundreds of store index JSON files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:46:38 +01:00
Lukas
635e9c0705 Add Dockerfile, nginx config, and Gitea Actions CI workflow
Some checks failed
CI / check (push) Failing after 6m20s
CI / build-image (push) Has been skipped
Multi-stage Docker build produces an Nginx container serving the
static SPA. The CI workflow runs pnpm check on every push and
builds/pushes a Docker image on semver tags.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:32:12 +01:00
Lukas
582a42e62d Change add creature placeholder to "+ Add combatants"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:50:09 +01:00
49 changed files with 3534 additions and 280 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
.pnpm-store
dist
coverage
.git
.claude
.specify
specs
docs

49
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,49 @@
name: CI
on:
push:
branches: [main]
tags: ["*"]
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: pnpm install --frozen-lockfile
- run: pnpm check
build-image:
if: startsWith(github.ref, 'refs/tags/')
needs: check
runs-on: host
steps:
- uses: actions/checkout@v4
- name: Log in to Gitea registry
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.bahamut.nitrix.one -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
- name: Build and push
run: |
IMAGE=git.bahamut.nitrix.one/dostulata/initiative
TAG=${GITHUB_REF#refs/tags/}
docker build -t $IMAGE:$TAG -t $IMAGE:latest .
docker push $IMAGE:$TAG
docker push $IMAGE:latest
- name: Deploy
run: |
IMAGE=git.bahamut.nitrix.one/dostulata/initiative
TAG=${GITHUB_REF#refs/tags/}
docker stop initiative || true
docker rm initiative || true
docker run -d --name initiative --restart unless-stopped -p 8080:80 $IMAGE:$TAG

View File

@@ -101,6 +101,7 @@ Speckit manages **what** to build (specs as living documents). RPI manages **how
- `specs/002-turn-tracking/` — rounds, turn order, advance/retreat, top bar
- `specs/003-combatant-state/` — HP, AC, conditions, concentration, initiative
- `specs/004-bestiary/` — search index, stat blocks, source management, panel UX
- `specs/005-player-characters/` — persistent player character templates (CRUD), search & add to encounters, color/icon visual distinction, `PlayerCharacterStore` port
## Constitution (key principles)
@@ -111,3 +112,10 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
3. **Clarification-First** — Ask before making non-trivial assumptions.
4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans.
5. **Spec-driven features** — Features are described in living specs; evolve existing specs via `/integrate-issue`, create new ones via `/speckit.specify`. Bug fixes and tooling changes do not require specs.
## Active Technologies
- TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Vite 6, Tailwind CSS v4, Lucide Reac (005-player-characters)
- localStorage (new key `"initiative:player-characters"`) (005-player-characters)
## Recent Changes
- 005-player-characters: Added TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Vite 6, Tailwind CSS v4, Lucide Reac

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM node:22-slim AS build
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/domain/package.json packages/domain/
COPY packages/application/package.json packages/application/
COPY apps/web/package.json apps/web/
RUN pnpm install --frozen-lockfile --ignore-scripts
COPY . .
RUN pnpm --filter web build
FROM nginx:alpine
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@@ -7,7 +7,8 @@ A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e
- **Initiative tracking** — add combatants (batch-add from bestiary, custom creatures with optional stats), roll initiative (manual or d20), cycle turns and rounds
- **Encounter state** — HP, AC, conditions, concentration tracking with visual status indicators
- **Bestiary integration** — import bestiary JSON sources, search creatures, and view full stat blocks
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB
- **Player characters** — create reusable player character templates with name, AC, HP, color, and icon; search and add them to encounters with pre-filled stats; manage (edit/delete) from a dedicated panel
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB; player characters stored independently
## Prerequisites

View File

@@ -3,9 +3,17 @@ import {
rollInitiativeUseCase,
} from "@initiative/application";
import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { ActionBar } from "./components/action-bar";
import { CombatantRow } from "./components/combatant-row";
import { CreatePlayerModal } from "./components/create-player-modal";
import { PlayerManagement } from "./components/player-management";
import { SourceManager } from "./components/source-manager";
import { StatBlockPanel } from "./components/stat-block-panel";
import { Toast } from "./components/toast";
@@ -13,11 +21,50 @@ 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";
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 topBarClass = settling
? " animate-slide-down-in"
: topBarExiting
? " absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
: "";
const showTopBar = !empty || topBarExiting;
return {
risingClass,
settlingClass,
topBarClass,
showTopBar,
onSettleEnd: () => setSettling(false),
onRiseEnd: () => setRising(false),
onTopBarExitEnd: () => setTopBarExiting(false),
};
}
export function App() {
const {
encounter,
@@ -34,9 +81,23 @@ export function App() {
toggleCondition,
toggleConcentration,
addFromBestiary,
addFromPlayerCharacter,
makeStore,
} = useEncounter();
const {
characters: playerCharacters,
createCharacter: createPlayerCharacter,
editCharacter: editPlayerCharacter,
deleteCharacter: deletePlayerCharacter,
} = usePlayerCharacters();
const [createPlayerOpen, setCreatePlayerOpen] = useState(false);
const [managementOpen, setManagementOpen] = useState(false);
const [editingPlayer, setEditingPlayer] = useState<
(typeof playerCharacters)[number] | undefined
>(undefined);
const {
search,
getCreature,
@@ -79,14 +140,6 @@ export function App() {
const handleAddFromBestiary = useCallback(
(result: SearchResult) => {
addFromBestiary(result);
// Derive the creature ID so stat block panel can try to show it
const slug = result.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
setSelectedCreatureId(
`${result.source.toLowerCase()}:${slug}` as CreatureId,
);
},
[addFromBestiary],
);
@@ -160,6 +213,9 @@ export function App() {
setPinnedCreatureId(null);
}, []);
const actionBarInputRef = useRef<HTMLInputElement>(null);
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
// Auto-scroll to the active combatant when the turn changes
const activeRowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -182,75 +238,109 @@ export function App() {
setSelectedCreatureId(active.creatureId as CreatureId);
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
const isEmpty = encounter.combatants.length === 0;
return (
<div className="flex h-screen flex-col">
<div className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0">
{/* Turn Navigation — fixed at top */}
<div className="shrink-0 pt-8">
<TurnNavigation
encounter={encounter}
onAdvanceTurn={advanceTurn}
onRetreatTurn={retreatTurn}
onClearEncounter={clearEncounter}
onRollAllInitiative={handleRollAllInitiative}
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
/>
</div>
{sourceManagerOpen && (
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
<SourceManager onCacheCleared={refreshCache} />
<div className="relative mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0">
{actionBarAnim.showTopBar && (
<div
className={`shrink-0 pt-8${actionBarAnim.topBarClass}`}
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
>
<TurnNavigation
encounter={encounter}
onAdvanceTurn={advanceTurn}
onRetreatTurn={retreatTurn}
onClearEncounter={clearEncounter}
onRollAllInitiative={handleRollAllInitiative}
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
/>
</div>
)}
{/* Scrollable area — combatant list */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="flex flex-col px-2 py-2">
{encounter.combatants.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground">
No combatants yet add one to get started
</p>
) : (
encounter.combatants.map((c, i) => (
<CombatantRow
key={c.id}
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
}
/>
))
)}
{isEmpty ? (
/* Empty state — ActionBar centered */
<div className="flex flex-1 items-center justify-center min-h-0 pb-[15%] pt-8">
<div
className={`w-full${actionBarAnim.risingClass}`}
onAnimationEnd={actionBarAnim.onRiseEnd}
>
<ActionBar
onAddCombatant={addCombatant}
onAddFromBestiary={handleAddFromBestiary}
bestiarySearch={search}
bestiaryLoaded={isLoaded}
onViewStatBlock={handleViewStatBlock}
onBulkImport={handleBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"}
inputRef={actionBarInputRef}
playerCharacters={playerCharacters}
onAddFromPlayerCharacter={addFromPlayerCharacter}
onManagePlayers={() => setManagementOpen(true)}
autoFocus
/>
</div>
</div>
</div>
) : (
<>
{sourceManagerOpen && (
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
<SourceManager onCacheCleared={refreshCache} />
</div>
)}
{/* Action Bar — fixed at bottom */}
<div className="shrink-0 pb-8">
<ActionBar
onAddCombatant={addCombatant}
onAddFromBestiary={handleAddFromBestiary}
bestiarySearch={search}
bestiaryLoaded={isLoaded}
onViewStatBlock={handleViewStatBlock}
onBulkImport={handleBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"}
/>
</div>
{/* Scrollable area — combatant list */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="flex flex-col px-2 py-2">
{encounter.combatants.map((c, i) => (
<CombatantRow
key={c.id}
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}`}
onAnimationEnd={actionBarAnim.onSettleEnd}
>
<ActionBar
onAddCombatant={addCombatant}
onAddFromBestiary={handleAddFromBestiary}
bestiarySearch={search}
bestiaryLoaded={isLoaded}
onViewStatBlock={handleViewStatBlock}
onBulkImport={handleBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"}
inputRef={actionBarInputRef}
playerCharacters={playerCharacters}
onAddFromPlayerCharacter={addFromPlayerCharacter}
onManagePlayers={() => setManagementOpen(true)}
/>
</div>
</>
)}
</div>
{/* Pinned Stat Block Panel (left) */}
@@ -321,6 +411,45 @@ export function App() {
onDismiss={bulkImport.reset}
/>
)}
<CreatePlayerModal
open={createPlayerOpen}
onClose={() => {
setCreatePlayerOpen(false);
setEditingPlayer(undefined);
}}
onSave={(name, ac, maxHp, color, icon) => {
if (editingPlayer) {
editPlayerCharacter?.(editingPlayer.id, {
name,
ac,
maxHp,
color,
icon,
});
} else {
createPlayerCharacter(name, ac, maxHp, color, icon);
}
}}
playerCharacter={editingPlayer}
/>
<PlayerManagement
open={managementOpen}
onClose={() => setManagementOpen(false)}
characters={playerCharacters}
onEdit={(pc) => {
setEditingPlayer(pc);
setCreatePlayerOpen(true);
setManagementOpen(false);
}}
onDelete={(id) => deletePlayerCharacter?.(id)}
onCreate={() => {
setEditingPlayer(undefined);
setCreatePlayerOpen(true);
setManagementOpen(false);
}}
/>
</div>
);
}

View File

@@ -54,11 +54,11 @@ describe("TurnNavigation", () => {
expect(container.textContent).not.toContain("—");
});
it("round badge and combatant name are in separate DOM elements", () => {
it("round badge and combatant name are siblings in the center area", () => {
renderNav();
const badge = screen.getByText("R1");
const name = screen.getByText("Goblin");
expect(badge.parentElement).not.toBe(name.parentElement);
expect(badge.parentElement).toBe(name.parentElement);
});
it("updates the round badge when round changes", () => {

View File

@@ -1,6 +1,14 @@
import { Check, Eye, Import, Minus, Plus } from "lucide-react";
import { type FormEvent, useEffect, useRef, useState } from "react";
import type { PlayerCharacter, PlayerIcon } from "@initiative/domain";
import { Check, Eye, Import, Minus, Plus, Users } from "lucide-react";
import {
type FormEvent,
type RefObject,
useEffect,
useRef,
useState,
} from "react";
import type { SearchResult } from "../hooks/use-bestiary.js";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
@@ -20,6 +28,11 @@ interface ActionBarProps {
onViewStatBlock?: (result: SearchResult) => void;
onBulkImport?: () => void;
bulkImportDisabled?: boolean;
inputRef?: RefObject<HTMLInputElement | null>;
playerCharacters?: readonly PlayerCharacter[];
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
onManagePlayers?: () => void;
autoFocus?: boolean;
}
function creatureKey(r: SearchResult): string {
@@ -34,9 +47,15 @@ export function ActionBar({
onViewStatBlock,
onBulkImport,
bulkImportDisabled,
inputRef,
playerCharacters,
onAddFromPlayerCharacter,
onManagePlayers,
autoFocus,
}: ActionBarProps) {
const [nameInput, setNameInput] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
const [suggestionIndex, setSuggestionIndex] = useState(-1);
const [queued, setQueued] = useState<QueuedCreature | null>(null);
const [customInit, setCustomInit] = useState("");
@@ -65,6 +84,7 @@ export function ActionBar({
setQueued(null);
setNameInput("");
setSuggestions([]);
setPcMatches([]);
setSuggestionIndex(-1);
};
@@ -91,6 +111,7 @@ export function ActionBar({
onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
setNameInput("");
setSuggestions([]);
setPcMatches([]);
clearCustomFields();
};
@@ -98,13 +119,22 @@ export function ActionBar({
setNameInput(value);
setSuggestionIndex(-1);
let newSuggestions: SearchResult[] = [];
let newPcMatches: PlayerCharacter[] = [];
if (value.length >= 2) {
newSuggestions = bestiarySearch(value);
setSuggestions(newSuggestions);
if (playerCharacters && playerCharacters.length > 0) {
const lower = value.toLowerCase();
newPcMatches = playerCharacters.filter((pc) =>
pc.name.toLowerCase().includes(lower),
);
}
setPcMatches(newPcMatches);
} else {
setSuggestions([]);
setPcMatches([]);
}
if (newSuggestions.length > 0) {
if (newSuggestions.length > 0 || newPcMatches.length > 0) {
clearCustomFields();
}
if (queued) {
@@ -133,8 +163,10 @@ export function ActionBar({
}
};
const hasSuggestions = suggestions.length > 0 || pcMatches.length > 0;
const handleKeyDown = (e: React.KeyboardEvent) => {
if (suggestions.length === 0) return;
if (!hasSuggestions) return;
if (e.key === "ArrowDown") {
e.preventDefault();
@@ -149,6 +181,7 @@ export function ActionBar({
setQueued(null);
setSuggestionIndex(-1);
setSuggestions([]);
setPcMatches([]);
}
};
@@ -222,99 +255,161 @@ export function ActionBar({
>
<div className="relative flex-1">
<Input
ref={inputRef}
type="text"
value={nameInput}
onChange={(e) => handleNameChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search creatures to add..."
placeholder="+ Add combatants"
className="max-w-xs"
autoFocus={autoFocus}
/>
{suggestions.length > 0 && (
{hasSuggestions && (
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
<ul className="max-h-48 overflow-y-auto py-1">
{suggestions.map((result, i) => {
const key = creatureKey(result);
const isQueued =
queued !== null && creatureKey(queued.result) === key;
return (
<li key={key}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
isQueued
? "bg-accent/30 text-foreground"
: i === suggestionIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleClickSuggestion(result)}
onMouseEnter={() => setSuggestionIndex(i)}
>
<span>{result.name}</span>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{isQueued ? (
<>
<button
type="button"
className="rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
if (queued.count <= 1) {
setQueued(null);
} else {
setQueued({
...queued,
count: queued.count - 1,
});
}
}}
>
<Minus className="h-3 w-3" />
</button>
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-foreground">
{queued.count}
<button
type="button"
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-2 text-left text-sm text-accent hover:bg-accent/20"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setSuggestions([]);
setPcMatches([]);
setQueued(null);
setSuggestionIndex(-1);
}}
>
<Plus className="h-3.5 w-3.5" />
<span className="flex-1">Add "{nameInput}" as custom</span>
<kbd className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground">
Esc
</kbd>
</button>
<div className="max-h-48 overflow-y-auto py-1">
{pcMatches.length > 0 && (
<>
<div className="px-3 py-1 text-xs font-medium text-muted-foreground">
Players
</div>
<ul>
{pcMatches.map((pc) => {
const PcIcon = PLAYER_ICON_MAP[pc.icon as PlayerIcon];
const pcColor =
PLAYER_COLOR_HEX[
pc.color as keyof typeof PLAYER_COLOR_HEX
];
return (
<li key={pc.id}>
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-hover-neutral-bg"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onAddFromPlayerCharacter?.(pc);
setNameInput("");
setSuggestions([]);
setPcMatches([]);
}}
>
{PcIcon && (
<PcIcon size={14} style={{ color: pcColor }} />
)}
<span className="flex-1 truncate">{pc.name}</span>
<span className="text-xs text-muted-foreground">
Player
</span>
<button
type="button"
className="rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
setQueued({
...queued,
count: queued.count + 1,
});
}}
>
<Plus className="h-3 w-3" />
</button>
<button
type="button"
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
confirmQueued();
}}
>
<Check className="h-3.5 w-3.5" />
</button>
</>
) : (
result.sourceDisplayName
)}
</span>
</button>
</li>
);
})}
</ul>
</button>
</li>
);
})}
</ul>
</>
)}
{suggestions.length > 0 && (
<ul>
{suggestions.map((result, i) => {
const key = creatureKey(result);
const isQueued =
queued !== null && creatureKey(queued.result) === key;
return (
<li key={key}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
isQueued
? "bg-accent/30 text-foreground"
: i === suggestionIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleClickSuggestion(result)}
onMouseEnter={() => setSuggestionIndex(i)}
>
<span>{result.name}</span>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{isQueued ? (
<>
<button
type="button"
className="rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
if (queued.count <= 1) {
setQueued(null);
} else {
setQueued({
...queued,
count: queued.count - 1,
});
}
}}
>
<Minus className="h-3 w-3" />
</button>
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-foreground">
{queued.count}
</span>
<button
type="button"
className="rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
setQueued({
...queued,
count: queued.count + 1,
});
}}
>
<Plus className="h-3 w-3" />
</button>
<button
type="button"
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
confirmQueued();
}}
>
<Check className="h-3.5 w-3.5" />
</button>
</>
) : (
result.sourceDisplayName
)}
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
</div>
)}
</div>
{nameInput.length >= 2 && suggestions.length === 0 && (
{nameInput.length >= 2 && !hasSuggestions && (
<div className="flex items-center gap-2">
<Input
type="text"
@@ -345,72 +440,93 @@ export function ActionBar({
<Button type="submit" size="sm">
Add
</Button>
{bestiaryLoaded && onViewStatBlock && (
<div ref={viewerRef} className="relative">
<div className="flex items-center gap-0">
{onManagePlayers && (
<Button
type="button"
size="sm"
size="icon"
variant="ghost"
onClick={() => (viewerOpen ? closeViewer() : openViewer())}
className="text-muted-foreground hover:text-hover-neutral"
onClick={onManagePlayers}
title="Player characters"
aria-label="Player characters"
>
<Eye className="h-4 w-4" />
<Users className="h-5 w-5" />
</Button>
{viewerOpen && (
<div className="absolute bottom-full right-0 z-50 mb-1 w-64 rounded-md border border-border bg-card shadow-lg">
<div className="p-2">
<Input
ref={viewerInputRef}
type="text"
value={viewerQuery}
onChange={(e) => handleViewerQueryChange(e.target.value)}
onKeyDown={handleViewerKeyDown}
placeholder="Search stat blocks..."
className="w-full"
/>
</div>
{viewerResults.length > 0 && (
<ul className="max-h-48 overflow-y-auto border-t border-border py-1">
{viewerResults.map((result, i) => (
<li key={creatureKey(result)}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
i === viewerIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
}`}
onClick={() => handleViewerSelect(result)}
onMouseEnter={() => setViewerIndex(i)}
>
<span>{result.name}</span>
<span className="text-xs text-muted-foreground">
{result.sourceDisplayName}
</span>
</button>
</li>
))}
</ul>
)}
{viewerQuery.length >= 2 && viewerResults.length === 0 && (
<div className="border-t border-border px-3 py-2 text-sm text-muted-foreground">
No creatures found
)}
{bestiaryLoaded && onViewStatBlock && (
<div ref={viewerRef} className="relative">
<Button
type="button"
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-hover-neutral"
onClick={() => (viewerOpen ? closeViewer() : openViewer())}
title="Browse stat blocks"
aria-label="Browse stat blocks"
>
<Eye className="h-5 w-5" />
</Button>
{viewerOpen && (
<div className="absolute bottom-full right-0 z-50 mb-1 w-64 rounded-md border border-border bg-card shadow-lg">
<div className="p-2">
<Input
ref={viewerInputRef}
type="text"
value={viewerQuery}
onChange={(e) => handleViewerQueryChange(e.target.value)}
onKeyDown={handleViewerKeyDown}
placeholder="Search stat blocks..."
className="w-full"
/>
</div>
)}
</div>
)}
</div>
)}
{bestiaryLoaded && onBulkImport && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={onBulkImport}
disabled={bulkImportDisabled}
>
<Import className="h-4 w-4" />
</Button>
)}
{viewerResults.length > 0 && (
<ul className="max-h-48 overflow-y-auto border-t border-border py-1">
{viewerResults.map((result, i) => (
<li key={creatureKey(result)}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
i === viewerIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
}`}
onClick={() => handleViewerSelect(result)}
onMouseEnter={() => setViewerIndex(i)}
>
<span>{result.name}</span>
<span className="text-xs text-muted-foreground">
{result.sourceDisplayName}
</span>
</button>
</li>
))}
</ul>
)}
{viewerQuery.length >= 2 && viewerResults.length === 0 && (
<div className="border-t border-border px-3 py-2 text-sm text-muted-foreground">
No creatures found
</div>
)}
</div>
)}
</div>
)}
{bestiaryLoaded && onBulkImport && (
<Button
type="button"
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-hover-neutral"
onClick={onBulkImport}
disabled={bulkImportDisabled}
title="Bulk import"
aria-label="Bulk import"
>
<Import className="h-5 w-5" />
</Button>
)}
</div>
</form>
</div>
);

View File

@@ -0,0 +1,36 @@
import { VALID_PLAYER_COLORS } from "@initiative/domain";
import { cn } from "../lib/utils";
import { PLAYER_COLOR_HEX } from "./player-icon-map";
interface ColorPaletteProps {
value: string;
onChange: (color: string) => void;
}
const COLORS = [...VALID_PLAYER_COLORS] as string[];
export function ColorPalette({ value, onChange }: ColorPaletteProps) {
return (
<div className="flex flex-wrap gap-2">
{COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => onChange(color)}
className={cn(
"h-8 w-8 rounded-full transition-all",
value === color
? "ring-2 ring-foreground ring-offset-2 ring-offset-background scale-110"
: "hover:scale-110",
)}
style={{
backgroundColor:
PLAYER_COLOR_HEX[color as keyof typeof PLAYER_COLOR_HEX],
}}
aria-label={color}
title={color}
/>
))}
</div>
);
}

View File

@@ -2,6 +2,7 @@ import {
type CombatantId,
type ConditionId,
deriveHpStatus,
type PlayerIcon,
} from "@initiative/domain";
import { Brain, X } from "lucide-react";
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
@@ -11,6 +12,7 @@ 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";
@@ -23,6 +25,8 @@ interface Combatant {
readonly ac?: number;
readonly conditions?: readonly ConditionId[];
readonly isConcentrating?: boolean;
readonly color?: string;
readonly icon?: string;
}
interface CombatantRowProps {
@@ -45,11 +49,13 @@ function EditableName({
combatantId,
onRename,
onShowStatBlock,
color,
}: {
name: string;
combatantId: CombatantId;
onRename: (id: CombatantId, newName: string) => void;
onShowStatBlock?: () => void;
color?: string;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(name);
@@ -139,6 +145,7 @@ function EditableName({
onTouchCancel={cancelLongPress}
onTouchMove={cancelLongPress}
className="truncate text-left text-sm text-foreground cursor-text hover:text-hover-neutral transition-colors"
style={color ? { color } : undefined}
>
{name}
</button>
@@ -478,6 +485,10 @@ export function CombatantRow({
}
}, [combatant.isConcentrating]);
const pcColor = combatant.color
? PLAYER_COLOR_HEX[combatant.color as keyof typeof PLAYER_COLOR_HEX]
: undefined;
return (
/* biome-ignore lint/a11y/noStaticElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
<div
@@ -535,11 +546,28 @@ export function CombatantRow({
dimmed && "opacity-50",
)}
>
{combatant.icon &&
combatant.color &&
(() => {
const PcIcon = PLAYER_ICON_MAP[combatant.icon as PlayerIcon];
const pcColor =
PLAYER_COLOR_HEX[
combatant.color as keyof typeof PLAYER_COLOR_HEX
];
return PcIcon ? (
<PcIcon
size={14}
style={{ color: pcColor }}
className="shrink-0"
/>
) : null;
})()}
<EditableName
name={name}
combatantId={id}
onRename={onRename}
onShowStatBlock={onShowStatBlock}
color={pcColor}
/>
<ConditionTags
conditions={combatant.conditions}

View File

@@ -0,0 +1,186 @@
import type { PlayerCharacter } from "@initiative/domain";
import { X } from "lucide-react";
import { type FormEvent, useEffect, useState } from "react";
import { ColorPalette } from "./color-palette";
import { IconGrid } from "./icon-grid";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
interface CreatePlayerModalProps {
open: boolean;
onClose: () => void;
onSave: (
name: string,
ac: number,
maxHp: number,
color: string,
icon: string,
) => void;
playerCharacter?: PlayerCharacter;
}
export function CreatePlayerModal({
open,
onClose,
onSave,
playerCharacter,
}: CreatePlayerModalProps) {
const [name, setName] = useState("");
const [ac, setAc] = useState("10");
const [maxHp, setMaxHp] = useState("10");
const [color, setColor] = useState("blue");
const [icon, setIcon] = useState("sword");
const [error, setError] = useState("");
const isEdit = !!playerCharacter;
useEffect(() => {
if (open) {
if (playerCharacter) {
setName(playerCharacter.name);
setAc(String(playerCharacter.ac));
setMaxHp(String(playerCharacter.maxHp));
setColor(playerCharacter.color);
setIcon(playerCharacter.icon);
} else {
setName("");
setAc("10");
setMaxHp("10");
setColor("blue");
setIcon("sword");
}
setError("");
}
}, [open, playerCharacter]);
useEffect(() => {
if (!open) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, onClose]);
if (!open) return null;
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (trimmed === "") {
setError("Name is required");
return;
}
const acNum = Number.parseInt(ac, 10);
if (Number.isNaN(acNum) || acNum < 0) {
setError("AC must be a non-negative number");
return;
}
const hpNum = Number.parseInt(maxHp, 10);
if (Number.isNaN(hpNum) || hpNum < 1) {
setError("Max HP must be at least 1");
return;
}
onSave(trimmed, acNum, hpNum, color, icon);
onClose();
};
return (
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onMouseDown={onClose}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
<div
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">
{isEdit ? "Edit Player" : "Create Player"}
</h2>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<span className="mb-1 block text-sm text-muted-foreground">
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-sm text-destructive">{error}</p>}
</div>
<div className="flex gap-3">
<div className="flex-1">
<span className="mb-1 block text-sm text-muted-foreground">
AC
</span>
<Input
type="text"
inputMode="numeric"
value={ac}
onChange={(e) => setAc(e.target.value)}
placeholder="AC"
aria-label="AC"
className="text-center"
/>
</div>
<div className="flex-1">
<span className="mb-1 block text-sm text-muted-foreground">
Max HP
</span>
<Input
type="text"
inputMode="numeric"
value={maxHp}
onChange={(e) => setMaxHp(e.target.value)}
placeholder="Max HP"
aria-label="Max HP"
className="text-center"
/>
</div>
</div>
<div>
<span className="mb-2 block text-sm text-muted-foreground">
Color
</span>
<ColorPalette value={color} onChange={setColor} />
</div>
<div>
<span className="mb-2 block text-sm text-muted-foreground">
Icon
</span>
<IconGrid value={icon} onChange={setIcon} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import type { PlayerIcon } from "@initiative/domain";
import { VALID_PLAYER_ICONS } from "@initiative/domain";
import { cn } from "../lib/utils";
import { PLAYER_ICON_MAP } from "./player-icon-map";
interface IconGridProps {
value: string;
onChange: (icon: string) => void;
}
const ICONS = [...VALID_PLAYER_ICONS] as PlayerIcon[];
export function IconGrid({ value, onChange }: IconGridProps) {
return (
<div className="flex flex-wrap gap-2">
{ICONS.map((iconId) => {
const Icon = PLAYER_ICON_MAP[iconId];
return (
<button
key={iconId}
type="button"
onClick={() => onChange(iconId)}
className={cn(
"flex h-9 w-9 items-center justify-center rounded-md transition-all",
value === iconId
? "bg-primary/20 ring-2 ring-primary text-foreground"
: "text-muted-foreground hover:bg-card hover:text-foreground",
)}
aria-label={iconId}
title={iconId}
>
<Icon size={20} />
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,50 @@
import type { PlayerColor, PlayerIcon } from "@initiative/domain";
import type { LucideIcon } from "lucide-react";
import {
Axe,
Crosshair,
Crown,
Eye,
Feather,
Flame,
Heart,
Moon,
Shield,
Skull,
Star,
Sun,
Sword,
Wand,
Zap,
} from "lucide-react";
export const PLAYER_ICON_MAP: Record<PlayerIcon, LucideIcon> = {
sword: Sword,
shield: Shield,
skull: Skull,
heart: Heart,
wand: Wand,
flame: Flame,
crown: Crown,
star: Star,
moon: Moon,
sun: Sun,
axe: Axe,
crosshair: Crosshair,
eye: Eye,
feather: Feather,
zap: Zap,
};
export const PLAYER_COLOR_HEX: Record<PlayerColor, string> = {
red: "#ef4444",
blue: "#3b82f6",
green: "#22c55e",
purple: "#a855f7",
orange: "#f97316",
pink: "#ec4899",
cyan: "#06b6d4",
yellow: "#eab308",
emerald: "#10b981",
indigo: "#6366f1",
};

View File

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

View File

@@ -47,7 +47,12 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
<span className="text-sm font-semibold text-foreground">
Cached Sources
</span>
<Button size="sm" variant="outline" onClick={handleClearAll}>
<Button
size="sm"
variant="outline"
className="hover:text-hover-destructive hover:border-hover-destructive"
onClick={handleClearAll}
>
<Trash2 className="mr-1 h-3 w-3" />
Clear All
</Button>
@@ -69,7 +74,7 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
<button
type="button"
onClick={() => handleClearSource(source.sourceCode)}
className="text-muted-foreground hover:text-hover-danger"
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-hover-destructive-bg hover:text-hover-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>

View File

@@ -1,5 +1,5 @@
import type { Encounter } from "@initiative/domain";
import { Settings, StepBack, StepForward, Trash2 } from "lucide-react";
import { Library, StepBack, StepForward, Trash2 } from "lucide-react";
import { D20Icon } from "./d20-icon";
import { Button } from "./ui/button";
import { ConfirmButton } from "./ui/confirm-button";
@@ -27,28 +27,22 @@ export function TurnNavigation({
return (
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
<div className="flex flex-shrink-0 items-center gap-3">
<Button
variant="outline"
size="icon"
className="h-8 w-8 text-foreground border-foreground hover:text-hover-action hover:border-hover-action hover:bg-transparent"
onClick={onRetreatTurn}
disabled={!hasCombatants || isAtStart}
title="Previous turn"
aria-label="Previous turn"
>
<StepBack className="h-5 w-5" />
</Button>
<span className="rounded-full bg-muted text-foreground text-sm px-2 py-0.5 font-semibold">
<Button
size="icon"
onClick={onRetreatTurn}
disabled={!hasCombatants || isAtStart}
title="Previous turn"
aria-label="Previous turn"
>
<StepBack className="h-5 w-5" />
</Button>
<div className="min-w-0 flex-1 flex items-center justify-center gap-2 text-sm">
<span className="rounded-full bg-muted text-foreground text-sm px-2 py-0.5 font-semibold shrink-0">
R{encounter.roundNumber}
</span>
</div>
<div className="min-w-0 flex-1 text-center text-sm">
{activeCombatant ? (
<span className="truncate block font-medium">
{activeCombatant.name}
</span>
<span className="truncate font-medium">{activeCombatant.name}</span>
) : (
<span className="text-muted-foreground">No combatants</span>
)}
@@ -59,7 +53,7 @@ export function TurnNavigation({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-hover-action"
className="text-muted-foreground hover:text-hover-action"
onClick={onRollAllInitiative}
title="Roll all initiative"
aria-label="Roll all initiative"
@@ -69,25 +63,23 @@ export function TurnNavigation({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-hover-neutral"
className="text-muted-foreground hover:text-hover-neutral"
onClick={onOpenSourceManager}
title="Manage cached sources"
aria-label="Manage cached sources"
>
<Settings className="h-5 w-5" />
<Library className="h-5 w-5" />
</Button>
<ConfirmButton
icon={<Trash2 className="h-5 w-5" />}
label="Clear encounter"
onConfirm={onClearEncounter}
disabled={!hasCombatants}
className="h-8 w-8 text-muted-foreground"
className="text-muted-foreground"
/>
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8 text-foreground border-foreground hover:text-hover-action hover:border-hover-action hover:bg-transparent"
onClick={onAdvanceTurn}
disabled={!hasCombatants}
title="Next turn"

View File

@@ -97,8 +97,9 @@ export function ConfirmButton({
size="icon"
className={cn(
className,
isConfirming &&
"bg-destructive text-primary-foreground rounded-md animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground",
isConfirming
? "bg-destructive text-primary-foreground rounded-md animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground"
: "hover:text-hover-destructive",
)}
onClick={handleClick}
onKeyDown={handleKeyDown}

View File

@@ -19,10 +19,10 @@ import type {
ConditionId,
DomainEvent,
Encounter,
PlayerCharacter,
} from "@initiative/domain";
import {
combatantId,
createEncounter,
isDomainError,
creatureId as makeCreatureId,
resolveCreatureName,
@@ -33,24 +33,16 @@ import {
saveEncounter,
} from "../persistence/encounter-storage.js";
function createDemoEncounter(): Encounter {
const result = createEncounter([
{ id: combatantId("1"), name: "Aria" },
{ id: combatantId("2"), name: "Brak" },
{ id: combatantId("3"), name: "Cael" },
]);
if (isDomainError(result)) {
throw new Error(`Failed to create demo encounter: ${result.message}`);
}
return result;
}
const EMPTY_ENCOUNTER: Encounter = {
combatants: [],
activeIndex: 0,
roundNumber: 1,
};
function initializeEncounter(): Encounter {
const stored = loadEncounter();
if (stored !== null) return stored;
return createDemoEncounter();
return EMPTY_ENCOUNTER;
}
function deriveNextId(encounter: Encounter): number {
@@ -327,6 +319,58 @@ export function useEncounter() {
[makeStore, editCombatant],
);
const addFromPlayerCharacter = useCallback(
(pc: PlayerCharacter) => {
const store = makeStore();
const existingNames = store.get().combatants.map((c) => c.name);
const { newName, renames } = resolveCreatureName(pc.name, existingNames);
for (const { from, to } of renames) {
const target = store.get().combatants.find((c) => c.name === from);
if (target) {
editCombatantUseCase(makeStore(), target.id, to);
}
}
const id = combatantId(`c-${++nextId.current}`);
const addResult = addCombatantUseCase(makeStore(), id, newName);
if (isDomainError(addResult)) return;
// Set HP
const hpResult = setHpUseCase(makeStore(), id, pc.maxHp);
if (!isDomainError(hpResult)) {
setEvents((prev) => [...prev, ...hpResult]);
}
// Set AC
if (pc.ac > 0) {
const acResult = setAcUseCase(makeStore(), id, pc.ac);
if (!isDomainError(acResult)) {
setEvents((prev) => [...prev, ...acResult]);
}
}
// Set color, icon, and playerCharacterId on the combatant
const currentEncounter = store.get();
store.save({
...currentEncounter,
combatants: currentEncounter.combatants.map((c) =>
c.id === id
? {
...c,
color: pc.color,
icon: pc.icon,
playerCharacterId: pc.id,
}
: c,
),
});
setEvents((prev) => [...prev, ...addResult]);
},
[makeStore, editCombatant],
);
return {
encounter,
events,
@@ -343,6 +387,7 @@ export function useEncounter() {
toggleCondition,
toggleConcentration,
addFromBestiary,
addFromPlayerCharacter,
makeStore,
} as const;
}

View File

@@ -0,0 +1,102 @@
import type { PlayerCharacterStore } from "@initiative/application";
import {
createPlayerCharacterUseCase,
deletePlayerCharacterUseCase,
editPlayerCharacterUseCase,
} from "@initiative/application";
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
import { isDomainError, playerCharacterId } from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react";
import {
loadPlayerCharacters,
savePlayerCharacters,
} from "../persistence/player-character-storage.js";
function initializeCharacters(): PlayerCharacter[] {
return loadPlayerCharacters();
}
let nextPcId = 0;
function generatePcId(): PlayerCharacterId {
return playerCharacterId(`pc-${++nextPcId}`);
}
interface EditFields {
readonly name?: string;
readonly ac?: number;
readonly maxHp?: number;
readonly color?: string;
readonly icon?: string;
}
export function usePlayerCharacters() {
const [characters, setCharacters] =
useState<PlayerCharacter[]>(initializeCharacters);
const charactersRef = useRef(characters);
charactersRef.current = characters;
useEffect(() => {
savePlayerCharacters(characters);
}, [characters]);
const makeStore = useCallback((): PlayerCharacterStore => {
return {
getAll: () => charactersRef.current,
save: (updated) => {
charactersRef.current = updated;
setCharacters(updated);
},
};
}, []);
const createCharacter = useCallback(
(name: string, ac: number, maxHp: number, color: string, icon: string) => {
const id = generatePcId();
const result = createPlayerCharacterUseCase(
makeStore(),
id,
name,
ac,
maxHp,
color,
icon,
);
if (isDomainError(result)) {
return result;
}
return undefined;
},
[makeStore],
);
const editCharacter = useCallback(
(id: PlayerCharacterId, fields: EditFields) => {
const result = editPlayerCharacterUseCase(makeStore(), id, fields);
if (isDomainError(result)) {
return result;
}
return undefined;
},
[makeStore],
);
const deleteCharacter = useCallback(
(id: PlayerCharacterId) => {
const result = deletePlayerCharacterUseCase(makeStore(), id);
if (isDomainError(result)) {
return result;
}
return undefined;
},
[makeStore],
);
return {
characters,
createCharacter,
editCharacter,
deleteCharacter,
makeStore,
} as const;
}

View File

@@ -80,6 +80,75 @@
}
}
@keyframes settle-to-bottom {
from {
transform: translateY(-40vh);
opacity: 0;
}
40% {
opacity: 1;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@utility animate-settle-to-bottom {
animation: settle-to-bottom 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
@keyframes rise-to-center {
from {
transform: translateY(40vh);
opacity: 0;
}
40% {
opacity: 1;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@utility animate-rise-to-center {
animation: rise-to-center 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
@keyframes slide-down-in {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@utility animate-slide-down-in {
animation: slide-down-in 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
@keyframes slide-up-out {
from {
transform: translateY(0);
opacity: 1;
}
60% {
opacity: 0;
}
to {
transform: translateY(-100%);
opacity: 0;
}
}
@utility animate-slide-up-out {
animation: slide-up-out 700ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
@custom-variant pointer-coarse (@media (pointer: coarse));
@utility animate-confirm-pulse {

View File

@@ -0,0 +1,231 @@
import type { PlayerCharacter } from "@initiative/domain";
import { playerCharacterId } from "@initiative/domain";
import { beforeEach, describe, expect, it } from "vitest";
import {
loadPlayerCharacters,
savePlayerCharacters,
} from "../player-character-storage.js";
const STORAGE_KEY = "initiative:player-characters";
function createMockLocalStorage() {
const store = new Map<string, string>();
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, value),
removeItem: (key: string) => store.delete(key),
clear: () => store.clear(),
get length() {
return store.size;
},
key: (_index: number) => null,
store,
};
}
function makePC(overrides?: Partial<PlayerCharacter>): PlayerCharacter {
return {
id: playerCharacterId("pc-1"),
name: "Aragorn",
ac: 16,
maxHp: 120,
color: "green",
icon: "sword",
...overrides,
};
}
describe("player-character-storage", () => {
let mockStorage: ReturnType<typeof createMockLocalStorage>;
beforeEach(() => {
mockStorage = createMockLocalStorage();
Object.defineProperty(globalThis, "localStorage", {
value: mockStorage,
writable: true,
});
});
describe("round-trip save/load", () => {
it("saves and loads a single character", () => {
const pc = makePC();
savePlayerCharacters([pc]);
const loaded = loadPlayerCharacters();
expect(loaded).toEqual([pc]);
});
it("saves and loads multiple characters", () => {
const pcs = [
makePC({ id: playerCharacterId("pc-1"), name: "Aragorn" }),
makePC({
id: playerCharacterId("pc-2"),
name: "Legolas",
ac: 14,
maxHp: 90,
color: "blue",
icon: "eye",
}),
];
savePlayerCharacters(pcs);
const loaded = loadPlayerCharacters();
expect(loaded).toEqual(pcs);
});
});
describe("empty storage", () => {
it("returns empty array when no data exists", () => {
expect(loadPlayerCharacters()).toEqual([]);
});
});
describe("corrupt JSON", () => {
it("returns empty array for invalid JSON", () => {
mockStorage.setItem(STORAGE_KEY, "not-json{{{");
expect(loadPlayerCharacters()).toEqual([]);
});
it("returns empty array for non-array JSON", () => {
mockStorage.setItem(STORAGE_KEY, '{"foo": "bar"}');
expect(loadPlayerCharacters()).toEqual([]);
});
});
describe("per-character validation", () => {
it("discards character with missing name", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{ id: "pc-1", ac: 10, maxHp: 50, color: "blue", icon: "sword" },
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("discards character with empty name", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{
id: "pc-1",
name: "",
ac: 10,
maxHp: 50,
color: "blue",
icon: "sword",
},
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("discards character with invalid color", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{
id: "pc-1",
name: "Test",
ac: 10,
maxHp: 50,
color: "neon",
icon: "sword",
},
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("discards character with invalid icon", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{
id: "pc-1",
name: "Test",
ac: 10,
maxHp: 50,
color: "blue",
icon: "banana",
},
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("discards character with negative AC", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{
id: "pc-1",
name: "Test",
ac: -1,
maxHp: 50,
color: "blue",
icon: "sword",
},
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("discards character with maxHp of 0", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{
id: "pc-1",
name: "Test",
ac: 10,
maxHp: 0,
color: "blue",
icon: "sword",
},
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("keeps valid characters and discards invalid ones", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{
id: "pc-1",
name: "Valid",
ac: 10,
maxHp: 50,
color: "blue",
icon: "sword",
},
{
id: "pc-2",
name: "",
ac: 10,
maxHp: 50,
color: "blue",
icon: "sword",
},
]),
);
const loaded = loadPlayerCharacters();
expect(loaded).toHaveLength(1);
expect(loaded[0].name).toBe("Valid");
});
});
describe("storage errors", () => {
it("save silently catches errors", () => {
Object.defineProperty(globalThis, "localStorage", {
value: {
setItem: () => {
throw new Error("QuotaExceeded");
},
getItem: () => null,
},
writable: true,
});
expect(() => savePlayerCharacters([makePC()])).not.toThrow();
});
});
});

View File

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

View File

@@ -0,0 +1,72 @@
import type { PlayerCharacter } from "@initiative/domain";
import {
playerCharacterId,
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
} from "@initiative/domain";
const STORAGE_KEY = "initiative:player-characters";
export function savePlayerCharacters(characters: PlayerCharacter[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(characters));
} catch {
// Silently swallow errors (quota exceeded, storage unavailable)
}
}
function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
return null;
const entry = raw as Record<string, unknown>;
if (typeof entry.id !== "string" || entry.id.length === 0) return null;
if (typeof entry.name !== "string" || entry.name.trim().length === 0)
return null;
if (
typeof entry.ac !== "number" ||
!Number.isInteger(entry.ac) ||
entry.ac < 0
)
return null;
if (
typeof entry.maxHp !== "number" ||
!Number.isInteger(entry.maxHp) ||
entry.maxHp < 1
)
return null;
if (typeof entry.color !== "string" || !VALID_PLAYER_COLORS.has(entry.color))
return null;
if (typeof entry.icon !== "string" || !VALID_PLAYER_ICONS.has(entry.icon))
return null;
return {
id: playerCharacterId(entry.id),
name: entry.name,
ac: entry.ac,
maxHp: entry.maxHp,
color: entry.color as PlayerCharacter["color"],
icon: entry.icon as PlayerCharacter["icon"],
};
}
export function loadPlayerCharacters(): PlayerCharacter[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
const characters: PlayerCharacter[] = [];
for (const item of parsed) {
const pc = rehydrateCharacter(item);
if (pc !== null) {
characters.push(pc);
}
}
return characters;
} catch {
return [];
}
}

View File

@@ -7,7 +7,8 @@
"!.claude/**",
"!.specify/**",
"!specs/**",
"!coverage/**"
"!coverage/**",
"!.pnpm-store/**"
]
},
"assist": {

9
nginx.conf Normal file
View File

@@ -0,0 +1,9 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -169,9 +169,9 @@ describe("advanceTurn", () => {
});
describe("invariants", () => {
it("INV-1: createEncounter rejects empty combatant list", () => {
it("INV-1: createEncounter accepts empty combatant list", () => {
const result = createEncounter([]);
expect(isDomainError(result)).toBe(true);
expect(isDomainError(result)).toBe(false);
});
it("INV-2: activeIndex always in bounds across all scenarios", () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ export function combatantId(id: string): CombatantId {
import type { ConditionId } from "./conditions.js";
import type { CreatureId } from "./creature-types.js";
import type { PlayerCharacterId } from "./player-character-types.js";
export interface Combatant {
readonly id: CombatantId;
@@ -18,6 +19,9 @@ export interface Combatant {
readonly conditions?: readonly ConditionId[];
readonly isConcentrating?: boolean;
readonly creatureId?: CreatureId;
readonly color?: string;
readonly icon?: string;
readonly playerCharacterId?: PlayerCharacterId;
}
export interface Encounter {
@@ -38,8 +42,8 @@ function domainError(code: string, message: string): DomainError {
/**
* Creates a valid Encounter, enforcing INV-1, INV-2, INV-3.
* - INV-1: At least one combatant required.
* - INV-2: activeIndex defaults to 0 (always in bounds).
* - INV-1: An encounter MAY have zero combatants.
* - INV-2: activeIndex defaults to 0 (always in bounds when combatants exist).
* - INV-3: roundNumber defaults to 1 (positive integer).
*/
export function createEncounter(
@@ -47,13 +51,10 @@ export function createEncounter(
activeIndex = 0,
roundNumber = 1,
): Encounter | DomainError {
if (combatants.length === 0) {
return domainError(
"invalid-encounter",
"An encounter must have at least one combatant",
);
}
if (activeIndex < 0 || activeIndex >= combatants.length) {
if (
combatants.length > 0 &&
(activeIndex < 0 || activeIndex >= combatants.length)
) {
return domainError(
"invalid-encounter",
`activeIndex ${activeIndex} out of bounds for ${combatants.length} combatants`,

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: Player Character Management
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-12
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All items pass validation. Spec is ready for `/speckit.clarify` or `/speckit.plan`.

View File

@@ -0,0 +1,93 @@
# UI Contracts: Player Character Management
**Branch**: `005-player-characters` | **Date**: 2026-03-12
## Bottom Bar — New "Create Player" Button
**Location**: Bottom bar, alongside existing search input
**Trigger**: Icon button click
**Icon**: `Users` (Lucide) or similar group/party icon
**Action**: Opens Create Player modal
## Create/Edit Player Modal
**Trigger**: "Create Player" button (create) or edit action in management view (edit)
**Layout**: Centered modal overlay
### Fields
| Field | Input Type | Placeholder/Label | Validation |
|-------|-----------|-------------------|------------|
| Name | Text input | "Character name" | Required, non-empty |
| AC | Number input | "AC" | Required, >= 0 |
| Max HP | Number input | "Max HP" | Required, > 0 |
| Color | Palette grid | — | Required, select one |
| Icon | Icon grid | — | Required, select one |
### Color Palette
Grid of ~10 color swatches. Selected color has a visible ring/border. Each swatch is a clickable circle or rounded square showing the color.
### Icon Grid
Grid of ~15 Lucide icons. Selected icon has a highlight/ring. Each icon is a clickable square with the icon rendered at a readable size.
### Actions
- **Save**: Validates and creates/updates. Closes modal on success.
- **Cancel**: Discards changes. Closes modal.
## Search Dropdown — Player Characters Section
**Location**: Existing ActionBar search dropdown
**Position**: Above bestiary results
**Visibility**: Only when player characters match the query (hide section when no matches)
### Result Item
| Element | Content |
|---------|---------|
| Icon | Player character's chosen icon (small, tinted with chosen color) |
| Name | Player character name |
| Label | "Player" (to distinguish from bestiary) |
### Behavior
- Clicking a player character result adds it to the encounter (same as bestiary selection)
- No count/batch — player characters are added one at a time
- Player character results use substring matching (same as bestiary)
## Combatant Row — Color & Icon Display
**Location**: Existing combatant row, next to combatant name
**Visibility**: Only for combatants with `color` and `icon` fields set
### Rendering
- Small icon (matching the player character's chosen icon) displayed to the left of the combatant name
- Icon tinted with the player character's chosen color
- Subtle color accent on the combatant row (e.g., left border or background tint)
## Player Character Management View
**Trigger**: Accessible from bottom bar or a dedicated affordance
**Layout**: Modal or slide-over panel
### Character List
Each row shows:
| Element | Content |
|---------|---------|
| Icon | Chosen icon, tinted with chosen color |
| Name | Character name |
| AC | Armor class value |
| Max HP | Max HP value |
| Edit button | Opens edit modal |
| Delete button | ConfirmButton (two-step) — removes character |
### Empty State
When no player characters exist:
- Message: "No player characters yet"
- Call-to-action button: "Create your first player character"

View File

@@ -0,0 +1,141 @@
# Data Model: Player Character Management
**Branch**: `005-player-characters` | **Date**: 2026-03-12
## New Entities
### PlayerCharacterId (branded type)
```
PlayerCharacterId = string & { __brand: "PlayerCharacterId" }
```
Unique identifier for player characters. Generated by the application layer (same pattern as `CombatantId`).
### PlayerCharacter
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| id | PlayerCharacterId | yes | Unique, immutable after creation |
| name | string | yes | Non-empty after trimming |
| ac | number | yes | Non-negative integer |
| maxHp | number | yes | Positive integer |
| color | PlayerColor | yes | One of predefined color values |
| icon | PlayerIcon | yes | One of predefined icon identifiers |
### PlayerColor (constrained string)
Predefined set of ~10 distinguishable color identifiers:
`"red" | "blue" | "green" | "purple" | "orange" | "pink" | "cyan" | "yellow" | "emerald" | "indigo"`
Each maps to a specific hex/tailwind value at the adapter layer.
### PlayerIcon (constrained string)
Predefined set of ~15 Lucide icon identifiers:
`"sword" | "shield" | "skull" | "heart" | "wand" | "flame" | "crown" | "star" | "moon" | "sun" | "axe" | "crosshair" | "eye" | "feather" | "zap"`
### PlayerCharacterList (aggregate)
| Field | Type | Constraints |
|-------|------|-------------|
| characters | readonly PlayerCharacter[] | May be empty. IDs unique within list. |
## Modified Entities
### Combatant (extended)
Three new optional fields added:
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| color | string | no | Copied from PlayerCharacter at add-time |
| icon | string | no | Copied from PlayerCharacter at add-time |
| playerCharacterId | PlayerCharacterId | no | Reference to source player character (informational only) |
These fields are set when a combatant is created from a player character. They are immutable snapshots — editing the source player character does not update existing combatants.
## State Transitions
### createPlayerCharacter
- **Input**: PlayerCharacterList + name + ac + maxHp + color + icon + id
- **Output**: Updated PlayerCharacterList + `PlayerCharacterCreated` event | DomainError
- **Validation**: name non-empty after trim, ac >= 0 integer, maxHp > 0 integer, color in set, icon in set
- **Errors**: `invalid-name`, `invalid-ac`, `invalid-max-hp`, `invalid-color`, `invalid-icon`
### editPlayerCharacter
- **Input**: PlayerCharacterList + id + partial fields (name?, ac?, maxHp?, color?, icon?)
- **Output**: Updated PlayerCharacterList + `PlayerCharacterUpdated` event | DomainError
- **Validation**: Same as create for any provided field. At least one field must change.
- **Errors**: `player-character-not-found`, `invalid-name`, `invalid-ac`, `invalid-max-hp`, `invalid-color`, `invalid-icon`
### deletePlayerCharacter
- **Input**: PlayerCharacterList + id
- **Output**: Updated PlayerCharacterList + `PlayerCharacterDeleted` event | DomainError
- **Validation**: ID must exist in list
- **Errors**: `player-character-not-found`
## Domain Events
### PlayerCharacterCreated
| Field | Type |
|-------|------|
| type | `"PlayerCharacterCreated"` |
| playerCharacterId | PlayerCharacterId |
| name | string |
### PlayerCharacterUpdated
| Field | Type |
|-------|------|
| type | `"PlayerCharacterUpdated"` |
| playerCharacterId | PlayerCharacterId |
| oldName | string |
| newName | string |
### PlayerCharacterDeleted
| Field | Type |
|-------|------|
| type | `"PlayerCharacterDeleted"` |
| playerCharacterId | PlayerCharacterId |
| name | string |
## Persistence Schema
### localStorage key: `"initiative:player-characters"`
JSON array of serialized `PlayerCharacter` objects:
```json
[
{
"id": "pc_abc123",
"name": "Aragorn",
"ac": 16,
"maxHp": 120,
"color": "green",
"icon": "sword"
}
]
```
### Rehydration rules
- Parse JSON array; discard entire store on parse failure (return empty list)
- Per-character validation: discard individual characters that fail validation
- Required string fields: must be non-empty strings
- Required number fields: must match domain constraints (ac >= 0, maxHp > 0)
- Color/icon: must be members of the predefined sets; discard character if invalid
## Port Interface
### PlayerCharacterStore
```
getAll(): PlayerCharacter[]
save(characters: PlayerCharacter[]): void
```
Synchronous, matching the `EncounterStore` pattern. Implementation: localStorage adapter.

View File

@@ -0,0 +1,100 @@
# Implementation Plan: Player Character Management
**Branch**: `005-player-characters` | **Date**: 2026-03-12 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/005-player-characters/spec.md`
## Summary
Add persistent player character templates with name, AC, max HP, color, and icon. Player characters are stored independently from encounters and appear in the combatant search dropdown. When added to an encounter, a combatant is created as an independent snapshot with the player character's stats, color, and icon. A management view allows editing and deleting saved characters.
## Technical Context
**Language/Version**: TypeScript 5.8 (strict mode, `verbatimModuleSyntax`)
**Primary Dependencies**: React 19, Vite 6, Tailwind CSS v4, Lucide React
**Storage**: localStorage (new key `"initiative:player-characters"`)
**Testing**: Vitest (unit tests for domain, persistence rehydration)
**Target Platform**: Web browser (single-page app, no routing)
**Project Type**: Web application (monorepo: domain → application → web adapter)
**Performance Goals**: Instant load (synchronous localStorage), <16ms search on small list
**Constraints**: Local-first, single-user, offline-capable
**Scale/Scope**: Typical party size 3-8 player characters
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Notes |
|-----------|--------|-------|
| I. Deterministic Domain Core | PASS | All PC operations are pure functions, no I/O |
| II. Layered Architecture | PASS | Domain types/functions → Application use cases/ports → Web adapters/hooks/components |
| III. Clarification-First | PASS | No non-trivial assumptions; all decisions documented in research.md |
| IV. Escalation Gates | PASS | Feature has its own spec; cross-feature impacts (Combatant type extension) are documented |
| V. MVP Baseline Language | PASS | Exclusions use "MVP baseline does not include" language |
| VI. No Gameplay Rules | PASS | No game mechanics in spec or plan |
**Post-Phase 1 re-check**: PASS. Data model uses pure domain types. Color/icon are constrained string sets validated in domain. Storage adapter follows existing localStorage pattern.
## Project Structure
### Documentation (this feature)
```text
specs/005-player-characters/
├── plan.md # This file
├── spec.md # Feature specification
├── research.md # Phase 0: technical decisions
├── data-model.md # Phase 1: entity definitions
├── quickstart.md # Phase 1: implementation guide
├── contracts/
│ └── ui-contracts.md # Phase 1: UI component contracts
├── checklists/
│ └── requirements.md # Spec quality checklist
└── tasks.md # Phase 2 output (/speckit.tasks)
```
### Source Code (repository root)
```text
packages/domain/src/
├── player-character-types.ts # NEW: PlayerCharacterId, PlayerCharacter, PlayerColor, PlayerIcon
├── create-player-character.ts # NEW: Pure create function
├── edit-player-character.ts # NEW: Pure edit function
├── delete-player-character.ts # NEW: Pure delete function
├── types.ts # MODIFIED: Add color?, icon?, playerCharacterId? to Combatant
├── events.ts # MODIFIED: Add PC domain events to union
├── index.ts # MODIFIED: Re-export new types/functions
└── __tests__/
├── create-player-character.test.ts # NEW
├── edit-player-character.test.ts # NEW
└── delete-player-character.test.ts # NEW
packages/application/src/
├── ports.ts # MODIFIED: Add PlayerCharacterStore
├── create-player-character-use-case.ts # NEW
├── edit-player-character-use-case.ts # NEW
├── delete-player-character-use-case.ts # NEW
└── index.ts # MODIFIED: Re-export
apps/web/src/
├── persistence/
│ ├── player-character-storage.ts # NEW: localStorage adapter
│ ├── encounter-storage.ts # MODIFIED: Handle new Combatant fields
│ └── __tests__/
│ └── player-character-storage.test.ts # NEW
├── hooks/
│ └── use-player-characters.ts # NEW: React state + persistence
├── components/
│ ├── create-player-modal.tsx # NEW: Create/edit modal
│ ├── player-management.tsx # NEW: List/edit/delete view
│ ├── color-palette.tsx # NEW: Color selection grid
│ ├── icon-grid.tsx # NEW: Icon selection grid
│ ├── action-bar.tsx # MODIFIED: Add "Players" section to dropdown
│ └── combatant-row.tsx # MODIFIED: Render color/icon
└── App.tsx # MODIFIED: Wire usePlayerCharacters
```
**Structure Decision**: Follows existing monorepo layered architecture. New domain files for player character operations, new application use cases, new web adapter/hook/components. Modified files extend existing types and UI.
## Complexity Tracking
No constitution violations. No complexity justification needed.

View File

@@ -0,0 +1,62 @@
# Quickstart: Player Character Management
**Branch**: `005-player-characters` | **Date**: 2026-03-12
## Overview
This feature adds persistent player character templates that can be reused across encounters. Player characters have a name, AC, max HP, a chosen color, and a preset icon.
## Implementation Layers
### Domain (`packages/domain/src/`)
New files:
- `player-character-types.ts``PlayerCharacterId`, `PlayerCharacter`, `PlayerColor`, `PlayerIcon`, validation sets
- `create-player-character.ts` — Pure create function
- `edit-player-character.ts` — Pure edit function
- `delete-player-character.ts` — Pure delete function
Modified files:
- `types.ts` — Add `color?`, `icon?`, `playerCharacterId?` to `Combatant`
- `events.ts` — Add `PlayerCharacterCreated`, `PlayerCharacterUpdated`, `PlayerCharacterDeleted` to union
- `index.ts` — Re-export new types and functions
### Application (`packages/application/src/`)
Modified files:
- `ports.ts` — Add `PlayerCharacterStore` port interface
New files:
- `create-player-character-use-case.ts`
- `edit-player-character-use-case.ts`
- `delete-player-character-use-case.ts`
### Web (`apps/web/`)
New files:
- `src/persistence/player-character-storage.ts` — localStorage adapter
- `src/hooks/use-player-characters.ts` — React state + persistence hook
- `src/components/create-player-modal.tsx` — Create/edit modal
- `src/components/player-management.tsx` — List/edit/delete view
- `src/components/color-palette.tsx` — Color selection grid
- `src/components/icon-grid.tsx` — Icon selection grid
Modified files:
- `src/components/action-bar.tsx` — Add "Players" section to search dropdown
- `src/components/combatant-row.tsx` — Render color/icon for PC combatants
- `src/App.tsx` — Wire up `usePlayerCharacters` hook, pass to components
- `src/persistence/encounter-storage.ts` — Handle new optional Combatant fields in rehydration
## Testing Strategy
- **Domain**: Pure function unit tests for create/edit/delete (validation, error cases, events)
- **Persistence**: Rehydration tests (corrupt data, missing fields, invalid color/icon)
- **Integration**: Layer boundary check already runs in CI — verify new domain files have no outer-layer imports
## Key Patterns to Follow
1. **Branded types**: See `CombatantId` in `types.ts` for pattern
2. **Domain operations**: See `add-combatant.ts` for `{result, events} | DomainError` pattern
3. **Persistence**: See `encounter-storage.ts` for localStorage + rehydration pattern
4. **Hook**: See `use-encounter.ts` for `useState` + `useEffect` persistence pattern
5. **Ports**: See `EncounterStore` in `ports.ts` for interface pattern

View File

@@ -0,0 +1,66 @@
# Research: Player Character Management
**Branch**: `005-player-characters` | **Date**: 2026-03-12
## Key Decisions
### 1. PlayerCharacter as a separate domain entity
- **Decision**: `PlayerCharacter` is a new type in the domain layer, distinct from `Combatant`. When added to an encounter, a snapshot is copied into a `Combatant`.
- **Rationale**: Player characters are persistent templates reused across encounters; combatants are ephemeral per-encounter instances. Mixing them would violate the single-responsibility of the `Encounter` aggregate.
- **Alternatives considered**: Extending `Combatant` with a `isPlayerCharacter` flag — rejected because combatants belong to encounters and are cleared with them, while player characters must survive encounter clears.
### 2. Combatant type extension for color and icon
- **Decision**: Add optional `color?: string` and `icon?: string` fields to the existing `Combatant` interface, plus `playerCharacterId?: PlayerCharacterId` to track origin.
- **Rationale**: The combatant row needs to render color/icon. Storing these on the combatant (copied from the player character at add-time) keeps the combatant self-contained and avoids runtime lookups against the player character store.
- **Alternatives considered**: Looking up color/icon from the player character store at render time — rejected because the player character might be deleted or edited after the combatant was added, and the spec says combatants are independent copies.
### 3. Storage: separate localStorage key
- **Decision**: Use `localStorage` with key `"initiative:player-characters"`, separate from encounter storage (`"initiative:encounter"`).
- **Rationale**: Follows existing pattern (encounter uses its own key). Player characters must survive encounter clears. IndexedDB is overkill for a small list of player characters.
- **Alternatives considered**: IndexedDB (like bestiary cache) — rejected as overly complex for simple JSON list. Shared key with encounter — rejected because clearing encounter would wipe player characters.
### 4. Search integration approach
- **Decision**: The `useBestiary` search hook or a new `usePlayerCharacters` hook provides player character search results. The `ActionBar` dropdown renders a "Players" group above bestiary results.
- **Rationale**: Player character search is a simple substring match on a small in-memory list — no index needed. Keeping it separate from bestiary search maintains separation of concerns.
- **Alternatives considered**: Merging into the bestiary index — rejected because player characters are user-created, not part of the pre-built index.
### 5. Color palette and icon set
- **Decision**: Use a fixed set of 10 distinguishable colors and ~15 Lucide icons already available in the project.
- **Rationale**: Lucide React is already a dependency. A fixed palette ensures visual consistency and simplifies the domain model (color is a string enum, not arbitrary hex).
- **Alternatives considered**: Arbitrary hex color picker — rejected for MVP as it complicates UX and validation.
### 6. Domain operations pattern
- **Decision**: Player character CRUD follows the same pattern as encounter operations: pure functions returning `{result, events} | DomainError`. New domain events: `PlayerCharacterCreated`, `PlayerCharacterUpdated`, `PlayerCharacterDeleted`.
- **Rationale**: Consistency with existing domain patterns. Events enable future features (undo, audit).
- **Alternatives considered**: Simpler CRUD without events — rejected for consistency with the project's event-driven domain.
### 7. Management view location
- **Decision**: A new icon button in the bottom bar (alongside the existing search) opens a player character management panel/modal.
- **Rationale**: The bottom bar already serves as the primary action area. A modal keeps the management view accessible without adding routing complexity.
- **Alternatives considered**: A separate route/page — rejected because the app is currently a single-page encounter tracker with no routing.
## Cross-feature impacts
### Spec 001 (Combatant Management)
- `Combatant` type gains three optional fields: `color`, `icon`, `playerCharacterId`
- `encounter-storage.ts` rehydration needs to handle new optional fields
- Combatant row component needs to render color/icon when present
### Spec 003 (Combatant State)
- No changes needed. AC and HP management already works on optional fields that player characters pre-fill.
### Spec 004 (Bestiary)
- ActionBar dropdown gains a "Players" section above bestiary results
- `addFromBestiary` pattern informs the new `addFromPlayerCharacter` flow
- No changes to bestiary search itself
## Unresolved items
None. All technical decisions are resolved.

View File

@@ -0,0 +1,262 @@
# Feature Specification: Player Character Management
**Feature Branch**: `005-player-characters`
**Created**: 2026-03-12
**Status**: Draft
**Input**: User description: "Allow users to create and manage Player characters via the bottom bar. Each player character has a name, AC, max HP, a chosen color, and a preset icon. Player characters persist across sessions and are searchable when adding combatants to an encounter. A dedicated management view lets users edit and delete existing player characters."
## User Scenarios & Testing *(mandatory)*
### Creating Player Characters
**Story PC-1 — Create a new player character (Priority: P1)**
A game master opens a "Create Player" modal from the bottom bar and fills in the character's name, AC, max HP, selects a color from a palette, and picks an icon from a preset grid. On saving, the player character is persisted and available for future encounters.
**Why this priority**: Creating player characters is the foundational action — nothing else in this feature works without it.
**Independent Test**: Can be fully tested by creating a player character and verifying it appears in the saved player list.
**Acceptance Scenarios**:
1. **Given** the bottom bar is visible, **When** the user clicks the "Create Player" icon button, **Then** a modal opens with fields for Name, AC, Max HP, a color palette, and an icon selection grid.
2. **Given** the create player modal is open, **When** the user fills in Name "Aragorn", AC 16, Max HP 120, selects the color green, and selects the shield icon, **Then** clicking save creates a player character with those attributes and closes the modal.
3. **Given** no player characters exist, **When** the user creates their first player character, **Then** it is persisted and appears in the player character list.
4. **Given** the create player modal is open, **When** the user submits with an empty name, **Then** a validation error is shown and the player character is not created.
5. **Given** the create player modal is open, **When** the user submits with a whitespace-only name, **Then** a validation error is shown and the player character is not created.
6. **Given** the create player modal is open, **When** the user clicks cancel or closes the modal, **Then** no player character is created and any entered data is discarded.
---
### Player Character Persistence
**Story PC-2 — Player characters survive page reload (Priority: P1)**
Player characters are long-lived entities that persist across browser sessions. Unlike encounter combatants which belong to a single encounter, player characters represent recurring party members that the GM reuses across many encounters.
**Why this priority**: Without persistence, users would need to recreate their party every session, defeating the purpose.
**Independent Test**: Can be tested by creating a player character, reloading the page, and verifying it still exists.
**Acceptance Scenarios**:
1. **Given** the user has created player characters, **When** the page is reloaded, **Then** all player characters are restored with their name, AC, max HP, color, and icon intact.
2. **Given** saved player character data is corrupt or malformed, **When** the page loads, **Then** the application starts with an empty player character list without crashing.
3. **Given** no saved player character data exists, **When** the page loads, **Then** the application starts with an empty player character list.
---
### Adding Player Characters to Encounters
**Story PC-3 — Search and add player characters as combatants (Priority: P1)**
When adding combatants to an encounter, the GM can search for their saved player characters by name. Selecting a player character adds it as a combatant with its saved stats (AC, max HP) pre-filled, along with its color and icon for visual identification.
**Why this priority**: This is the core value proposition — reusing pre-configured characters instead of re-entering stats every encounter.
**Independent Test**: Can be tested by creating a player character, then adding it to an encounter via the combatant search.
**Acceptance Scenarios**:
1. **Given** player characters "Aragorn" and "Legolas" exist, **When** the user types "Ara" in the combatant search field, **Then** "Aragorn" appears in the search results alongside bestiary creatures.
2. **Given** player character "Aragorn" (AC 16, Max HP 120) exists, **When** the user selects "Aragorn" from search results, **Then** a combatant is added to the encounter with name "Aragorn", AC 16, max HP 120, current HP 120, and the player character's color and icon.
3. **Given** player character "Gandalf" exists, **When** the user adds "Gandalf" to two separate encounters (or the same encounter twice), **Then** each combatant is an independent copy — modifying one combatant's HP does not affect the other or the saved player character.
4. **Given** no player characters exist, **When** the user searches in the combatant search field, **Then** only bestiary creatures appear in results (no empty "Players" section is shown).
---
### Displaying Player Characters in Encounters
**Story PC-4 — Visual distinction for player character combatants (Priority: P2)**
Combatants originating from player characters display their chosen color and icon next to their name in the combatant row, making it easy to visually distinguish PCs from monsters at a glance.
**Why this priority**: Color and icon display enhances usability but the feature is functional without it.
**Independent Test**: Can be tested by adding a player character to an encounter and verifying the color and icon render in the combatant row.
**Acceptance Scenarios**:
1. **Given** a combatant was added from a player character with color green and the shield icon, **When** the combatant row is rendered, **Then** the player character's icon is displayed next to the name and the color is applied as a visual accent (e.g., colored border, background tint, or icon tint).
2. **Given** a combatant was added from the bestiary (not a player character), **When** the combatant row is rendered, **Then** no player character icon or color accent is shown.
---
### Managing Player Characters
**Story PC-5 — View all saved player characters (Priority: P2)**
The GM can access a dedicated management view to see all their saved player characters at a glance, with each character's name, AC, max HP, color, and icon displayed.
**Why this priority**: Management view is needed for editing and deleting, but basic create/add flow works without it.
**Independent Test**: Can be tested by creating several player characters and opening the management view to verify all are listed.
**Acceptance Scenarios**:
1. **Given** player characters exist, **When** the user opens the player character management view, **Then** all saved player characters are listed showing their name, AC, max HP, color, and icon.
2. **Given** no player characters exist, **When** the user opens the management view, **Then** an empty state message is shown encouraging the user to create their first player character.
---
**Story PC-6 — Edit an existing player character (Priority: P2)**
The GM realizes a player character's stats have changed (e.g., level up) or wants to fix a typo. They open the management view and edit the character's attributes.
**Why this priority**: Editing is important for ongoing campaigns but the feature delivers value without it initially.
**Independent Test**: Can be tested by editing a player character's name and stats and verifying the changes persist.
**Acceptance Scenarios**:
1. **Given** player character "Aragorn" exists, **When** the user edits "Aragorn" to change the name to "Strider" and AC to 18, **Then** the changes are saved and reflected in the player character list.
2. **Given** a player character is being edited, **When** the user submits with an empty name, **Then** a validation error is shown and the changes are not saved.
3. **Given** a player character is being edited, **When** the user cancels the edit, **Then** the original values are preserved.
4. **Given** a player character was previously added to an encounter as a combatant, **When** the player character is edited, **Then** existing combatants in the current encounter are not affected (they are independent copies).
---
**Story PC-7 — Delete a player character (Priority: P2)**
The GM no longer needs a player character and wants to remove it from their saved list.
**Why this priority**: Deletion keeps the list manageable but is not needed for core functionality.
**Independent Test**: Can be tested by deleting a player character and verifying it no longer appears in the list or search results.
**Acceptance Scenarios**:
1. **Given** player character "Boromir" exists, **When** the user deletes "Boromir" with confirmation, **Then** the player character is removed from the saved list.
2. **Given** player character "Boromir" was previously added to the current encounter as a combatant, **When** the player character is deleted, **Then** the combatant in the encounter is not affected (it is an independent copy).
3. **Given** the delete action is initiated, **When** the user is asked to confirm, **Then** the confirmation follows the existing ConfirmButton two-step pattern (as defined in spec 001).
---
### Edge Cases
- **Duplicate player character names**: Permitted. Player characters are identified by a unique internal ID, not by name.
- **Adding the same player character to an encounter multiple times**: Each addition creates an independent combatant copy. Multiple copies of the same PC in one encounter are allowed.
- **Editing a player character while it is also a combatant in the active encounter**: The active combatant is not affected; only future additions use the updated stats.
- **Deleting a player character while it is a combatant in the active encounter**: The combatant remains in the encounter unchanged.
- **Very long player character names**: The UI should truncate or ellipsize names that exceed the available space.
- **Browser storage quota exceeded**: Player character persistence silently fails; the current in-memory session continues.
- **Corrupt player character data on load**: The application discards corrupt data and starts with an empty player character list.
- **Color/icon rendering on different screen sizes**: Color and icon must remain visible and distinguishable at all supported viewport sizes.
- **Search ranking**: When searching, player characters should appear in a distinct group (e.g., "Players" section) above or alongside bestiary results to make them easy to find.
---
## Requirements *(mandatory)*
### Functional Requirements
#### FR-001 — Create: Modal via bottom bar
The system MUST provide an icon button in the bottom bar that opens a "Create Player" modal.
#### FR-002 — Create: Required fields
The create modal MUST include fields for Name (text), AC (number), and Max HP (number).
#### FR-003 — Create: Color selection
The create modal MUST include a color palette allowing the user to select one color from a predefined set of distinguishable colors.
#### FR-004 — Create: Icon selection
The create modal MUST include a grid of ~10-20 preset icons (e.g., sword, shield, skull, heart, wand) from which the user selects one.
#### FR-005 — Create: Name validation
Creating a player character MUST reject empty or whitespace-only names, showing a validation error.
#### FR-006 — Create: Unique identity
Each player character MUST be assigned a unique internal identifier on creation.
#### FR-007 — Persistence: Cross-session storage
Player characters MUST be persisted to browser storage and restored on page load.
#### FR-008 — Persistence: Independent from encounter storage
Player character storage MUST be separate from encounter storage — clearing an encounter does not affect saved player characters.
#### FR-009 — Persistence: Graceful degradation
The system MUST NOT crash when player character data is missing, corrupt, or storage is unavailable. It MUST fall back to an empty player character list.
#### FR-010 — Search: Player characters in combatant search
Player characters MUST appear in the combatant search results when the user searches for combatants to add to an encounter. Matching is by name substring.
#### FR-011 — Search: Distinct grouping
Player character results MUST be visually distinguishable from bestiary creature results in the search dropdown.
#### FR-012 — Add to encounter: Pre-filled stats
When a player character is added to an encounter as a combatant, the combatant MUST be created with the player character's name, AC, max HP, and current HP set to max HP.
#### FR-013 — Add to encounter: Color and icon association
When a combatant is created from a player character, the combatant MUST carry the player character's color and icon for display purposes.
#### FR-014 — Add to encounter: Independent copy
Combatants created from player characters MUST be independent copies. Changes to the combatant's stats during an encounter do not modify the saved player character, and vice versa.
#### FR-015 — Display: Color and icon in combatant row
Combatant rows for player-character-originating combatants MUST display the chosen icon and color accent.
#### FR-016 — Management: View all player characters
The system MUST provide a view listing all saved player characters with their name, AC, max HP, color, and icon.
#### FR-017 — Management: Edit player character
The system MUST allow editing a player character's name, AC, max HP, color, and icon. Edits MUST be persisted.
#### FR-018 — Management: Delete player character
The system MUST allow deleting a player character with two-step confirmation (ConfirmButton pattern from spec 001).
#### FR-019 — Management: Delete does not affect active combatants
Deleting a player character MUST NOT remove or modify any combatants currently in an encounter.
### Key Entities
- **PlayerCharacter**: A persistent, reusable character template with a unique `PlayerCharacterId` (branded string), required `name`, `ac` (number), `maxHp` (number), `color` (string from predefined set), and `icon` (string identifier from preset icon set).
- **PlayerCharacterStore** (port): Interface for loading, saving, and deleting player characters. Implemented as a browser storage adapter.
---
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Users can create a player character with name, AC, max HP, color, and icon in under 30 seconds.
- **SC-002**: Player characters persist across page reloads with all attributes intact.
- **SC-003**: Player characters appear in combatant search results and can be added to an encounter in a single selection.
- **SC-004**: Combatants created from player characters display their color and icon in the initiative tracker.
- **SC-005**: Users can edit any attribute of a saved player character and see the change persisted immediately.
- **SC-006**: Deleting a player character requires two deliberate user interactions (ConfirmButton pattern) and does not affect active encounter combatants.
- **SC-007**: All player character domain operations (create, edit, delete) are pure functions with no I/O, consistent with the project's deterministic domain core.
- **SC-008**: The player character domain module has zero imports from application, adapter, or UI layers.
- **SC-009**: Corrupt or missing player character data never causes a crash — the application gracefully falls back to an empty player character list.
---
## Assumptions
- Player character IDs are generated by the caller (application layer), keeping domain functions pure.
- The predefined color palette contains 8-12 visually distinct colors suitable for both light and dark backgrounds.
- The preset icon set uses Lucide React icons already available in the project, requiring no additional icon dependencies.
- Player characters are stored in a separate `localStorage` key from encounter data.
- Name validation trims whitespace; a name that is empty after trimming is invalid.
- Duplicate player character names are permitted — characters are distinguished by their unique ID.
- MVP baseline does not include importing/exporting player characters.
- MVP baseline does not include player-character-specific fields beyond name, AC, max HP, color, and icon (e.g., no class, level, or ability scores).
- MVP baseline does not include reordering player characters in the management view.
- The management view is accessible from the bottom bar or a dedicated UI affordance, separate from the encounter view.
- When a player character is added to an encounter, a snapshot of its current stats is copied — future edits to the player character do not retroactively update existing combatants.

View File

@@ -0,0 +1,252 @@
# Tasks: Player Character Management
**Input**: Design documents from `/specs/005-player-characters/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/ui-contracts.md, quickstart.md
**Tests**: Included — `pnpm check` merge gate requires passing tests with coverage.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
---
## Phase 1: Setup
**Purpose**: No new project setup needed — existing monorepo structure is used. This phase handles shared type foundations.
_(No tasks — the project is already set up. Foundational tasks cover all shared work.)_
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Domain types, events, and port interface that ALL user stories depend on.
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
- [x] T001 [P] Define `PlayerCharacterId` branded type, `PlayerCharacter` interface, `PlayerColor` constrained type (10 colors), `PlayerIcon` constrained type (~15 Lucide icon identifiers), validation sets (`VALID_PLAYER_COLORS`, `VALID_PLAYER_ICONS`), and `PlayerCharacterList` aggregate type (`{ readonly characters: readonly PlayerCharacter[] }`) in `packages/domain/src/player-character-types.ts`
- [x] T002 [P] Add optional `color?: string`, `icon?: string`, and `playerCharacterId?: PlayerCharacterId` fields to the `Combatant` interface in `packages/domain/src/types.ts`. Import `PlayerCharacterId` from `player-character-types.js`.
- [x] T003 [P] Add `PlayerCharacterCreated`, `PlayerCharacterUpdated`, `PlayerCharacterDeleted` event types to the `DomainEvent` union in `packages/domain/src/events.ts` (see data-model.md for field definitions)
- [x] T004 [P] Add `PlayerCharacterStore` port interface (`getAll(): PlayerCharacter[]`, `save(characters: PlayerCharacter[]): void`) to `packages/application/src/ports.ts`
- [x] T005 Re-export all new types and functions from `packages/domain/src/index.ts` and `packages/application/src/index.ts`
- [x] T006 Update encounter storage rehydration in `apps/web/src/persistence/encounter-storage.ts` to handle the new optional `color`, `icon`, and `playerCharacterId` fields on `Combatant` (validate as optional strings, discard invalid values)
**Checkpoint**: Foundation ready — domain types defined, Combatant extended, events added, port declared.
---
## Phase 3: User Story 1 — Create & Persist Player Characters (Priority: P1) 🎯 MVP
**Goal**: Users can create player characters via a modal (name, AC, max HP, color, icon) and they persist across page reloads.
**Independent Test**: Create a player character, reload the page, verify it still exists with all attributes intact.
**Maps to**: Spec stories PC-1 (Create) and PC-2 (Persistence)
### Domain
- [x] T007 [P] [US1] Implement `createPlayerCharacter` pure function in `packages/domain/src/create-player-character.ts` — accepts `PlayerCharacter[]`, `PlayerCharacterId`, name, ac, maxHp, color, icon; returns updated list + `PlayerCharacterCreated` event or `DomainError`. Validate: name non-empty after trim, ac >= 0 integer, maxHp > 0 integer, color in `VALID_PLAYER_COLORS`, icon in `VALID_PLAYER_ICONS`.
- [x] T008 [P] [US1] Write unit tests for `createPlayerCharacter` in `packages/domain/src/__tests__/create-player-character.test.ts` — cover: valid creation, empty name, whitespace name, invalid ac, invalid maxHp, invalid color, invalid icon, event emission.
### Application
- [x] T009 [US1] Implement `createPlayerCharacterUseCase` in `packages/application/src/create-player-character-use-case.ts` — accepts `PlayerCharacterStore`, id, name, ac, maxHp, color, icon; calls domain function and `store.save()`.
### Persistence Adapter
- [x] T010 [P] [US1] Implement `savePlayerCharacters(characters: PlayerCharacter[]): void` and `loadPlayerCharacters(): PlayerCharacter[]` in `apps/web/src/persistence/player-character-storage.ts` — localStorage key `"initiative:player-characters"`, silent catch on save errors, return empty array on corrupt/missing data. Per-character rehydration with field validation (discard invalid characters).
- [x] T011 [P] [US1] Write tests for player character storage in `apps/web/src/persistence/__tests__/player-character-storage.test.ts` — cover: round-trip save/load, corrupt JSON, missing fields, invalid color/icon values, empty storage, storage errors.
### React Hook
- [x] T012 [US1] Implement `usePlayerCharacters` hook in `apps/web/src/hooks/use-player-characters.ts``useState` initialized from `loadPlayerCharacters()`, `useEffect` to persist on change, expose `characters`, `createCharacter(name, ac, maxHp, color, icon)`, and a `makeStore()` callback returning `PlayerCharacterStore`. Follow the `useEncounter` ref + effect pattern.
### UI Components
- [x] T013 [P] [US1] Create `ColorPalette` component in `apps/web/src/components/color-palette.tsx` — renders a grid of color swatches from `VALID_PLAYER_COLORS`, highlights selected color with a ring/border, accepts `value` and `onChange` props.
- [x] T014 [P] [US1] Create `IconGrid` component in `apps/web/src/components/icon-grid.tsx` — renders a grid of Lucide icons from `VALID_PLAYER_ICONS`, highlights selected icon, accepts `value` and `onChange` props. Map icon identifiers to Lucide components.
- [x] T015 [US1] Create `CreatePlayerModal` component in `apps/web/src/components/create-player-modal.tsx` — modal with Name (text), AC (number), Max HP (number) fields, `ColorPalette`, `IconGrid`, Save and Cancel buttons. Name validation error display. Truncate/ellipsize long names in preview. Props: `open`, `onClose`, `onSave(name, ac, maxHp, color, icon)`.
- [x] T016 [US1] Add "Create Player" icon button to the bottom bar in `apps/web/src/components/action-bar.tsx` (e.g., `Users` Lucide icon). Wire it to open the `CreatePlayerModal`.
- [x] T017 [US1] Wire `usePlayerCharacters` hook in `apps/web/src/App.tsx` — call hook at app level, pass `createCharacter` to the `CreatePlayerModal` via `ActionBar`.
**Checkpoint**: Users can create player characters with all attributes, and they persist across page reloads. This is the MVP.
---
## Phase 4: User Story 2 — Search & Add to Encounter (Priority: P1)
**Goal**: Player characters appear in the combatant search dropdown and can be added to an encounter with stats pre-filled.
**Independent Test**: Create a player character, type its name in the search field, select it, verify combatant is added with correct stats, color, and icon.
**Maps to**: Spec story PC-3
### Implementation
- [x] T018 [US2] Add player character search to `ActionBar` in `apps/web/src/components/action-bar.tsx` — accept a `playerCharacters` prop (or search function), filter by name substring, render a "Players" group above bestiary results in the dropdown. Hide section when no matches.
- [x] T019 [US2] Implement `addFromPlayerCharacter` callback in `apps/web/src/hooks/use-encounter.ts` — accepts a `PlayerCharacter`, creates a combatant with name, ac, maxHp, currentHp=maxHp, color, icon, and playerCharacterId. Use `resolveCreatureName` for name conflict resolution (same pattern as `addFromBestiary`).
- [x] T020 [US2] Wire search and add in `apps/web/src/App.tsx` — pass `playerCharacters` list and `addFromPlayerCharacter` handler to `ActionBar`. On player character selection, call `addFromPlayerCharacter`.
**Checkpoint**: Player characters are searchable and addable to encounters with pre-filled stats.
---
## Phase 5: User Story 3 — Visual Distinction in Combatant Row (Priority: P2)
**Goal**: Combatants from player characters display their color and icon in the initiative tracker.
**Independent Test**: Add a player character to an encounter, verify the combatant row shows the chosen icon and color accent.
**Maps to**: Spec story PC-4
### Implementation
- [x] T021 [US3] Update `CombatantRow` in `apps/web/src/components/combatant-row.tsx` — if combatant has `color` and `icon` fields, render the Lucide icon (small, tinted with color) to the left of the name. Apply a subtle color accent (e.g., left border or background tint). Ensure long names truncate with ellipsis. Map icon string identifiers to Lucide components (reuse mapping from `IconGrid`).
**Checkpoint**: Player character combatants are visually distinct from bestiary/custom combatants.
---
## Phase 6: User Story 4 — Management View (Priority: P2)
**Goal**: Users can view, edit, and delete saved player characters from a dedicated management panel.
**Independent Test**: Open management view, verify all characters listed, edit one's name and AC, delete another, verify changes persisted.
**Maps to**: Spec stories PC-5 (View), PC-6 (Edit), PC-7 (Delete)
### Domain
- [x] T022 [P] [US4] Implement `editPlayerCharacter` pure function in `packages/domain/src/edit-player-character.ts` — accepts `PlayerCharacter[]`, id, and partial update fields; returns updated list + `PlayerCharacterUpdated` event or `DomainError`. Validate changed fields same as create. Return `DomainError` if no fields actually change (no-op guard).
- [x] T023 [P] [US4] Implement `deletePlayerCharacter` pure function in `packages/domain/src/delete-player-character.ts` — accepts `PlayerCharacter[]` and id; returns updated list + `PlayerCharacterDeleted` event or `DomainError`. Error if id not found.
- [x] T024 [P] [US4] Write unit tests for `editPlayerCharacter` in `packages/domain/src/__tests__/edit-player-character.test.ts` — cover: valid edit, not-found, invalid fields, no-op edit (no fields changed), event emission.
- [x] T025 [P] [US4] Write unit tests for `deletePlayerCharacter` in `packages/domain/src/__tests__/delete-player-character.test.ts` — cover: valid delete, not-found, event emission.
### Application
- [x] T026 [P] [US4] Implement `editPlayerCharacterUseCase` in `packages/application/src/edit-player-character-use-case.ts`
- [x] T027 [P] [US4] Implement `deletePlayerCharacterUseCase` in `packages/application/src/delete-player-character-use-case.ts`
### React Hook
- [x] T028 [US4] Extend `usePlayerCharacters` hook in `apps/web/src/hooks/use-player-characters.ts` — add `editCharacter(id, updates)` and `deleteCharacter(id)` methods using the new use cases.
### UI Components
- [x] T029 [US4] Extend `CreatePlayerModal` in `apps/web/src/components/create-player-modal.tsx` to support edit mode — accept optional `playerCharacter` prop to pre-fill fields, change title to "Edit Player", save calls `editCharacter` instead of `createCharacter`.
- [x] T030 [US4] Create `PlayerManagement` component in `apps/web/src/components/player-management.tsx` — modal/panel listing all player characters (name, AC, max HP, color icon). Truncate/ellipsize long names. Each row has an edit button (opens modal in edit mode) and a delete button (`ConfirmButton` pattern from spec 001). Empty state with "Create your first player character" CTA.
- [x] T031 [US4] Add management view trigger to the UI — icon button in bottom bar or within `ActionBar` that opens `PlayerManagement`. Wire in `apps/web/src/App.tsx` with `editCharacter` and `deleteCharacter` handlers.
**Checkpoint**: Full CRUD for player characters — create, view, edit, delete all working and persisted.
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Final integration validation and cleanup.
- [x] T032 Extract shared icon identifier → Lucide component mapping into a utility (used by `IconGrid`, `CombatantRow`, and `PlayerManagement`) to avoid duplication, e.g., `apps/web/src/components/player-icon-map.ts`
- [x] T033 Run `pnpm check` (audit + knip + biome + typecheck + test/coverage + jscpd) and fix any issues
- [x] T034 Update `CLAUDE.md` to add `specs/005-player-characters/` to the current feature specs list and document the `PlayerCharacterStore` port
- [x] T035 Update `README.md` if it documents user-facing features (per constitution: features that alter what the product does must be reflected)
---
## Dependencies & Execution Order
### Phase Dependencies
- **Foundational (Phase 2)**: No dependencies — can start immediately
- **US1 Create & Persist (Phase 3)**: Depends on Phase 2 completion
- **US2 Search & Add (Phase 4)**: Depends on Phase 3 (needs characters to exist and hook to be wired)
- **US3 Visual Distinction (Phase 5)**: Depends on Phase 4 (needs combatants with color/icon to exist)
- **US4 Management (Phase 6)**: Depends on Phase 3 (needs create flow and hook, but NOT on US2/US3)
- **Polish (Phase 7)**: Depends on all story phases complete
### User Story Dependencies
- **US1 (Create & Persist)**: Depends only on Foundational — can start immediately after Phase 2
- **US2 (Search & Add)**: Depends on US1 (needs `usePlayerCharacters` hook and characters to search)
- **US3 (Visual Distinction)**: Depends on US2 (needs combatants with color/icon fields populated)
- **US4 (Management)**: Depends on US1 only (needs hook and create flow). Can run in parallel with US2/US3.
### Within Each User Story
- Domain functions before application use cases
- Application use cases before React hooks
- React hooks before UI components
- Tests can run in parallel with their domain functions (written to same-phase files)
### Parallel Opportunities
Within Phase 2: T001, T002, T003, T004 can all run in parallel (different files)
Within US1: T007+T008 parallel with T010+T011, parallel with T013+T014
Within US4: T022-T027 can all run in parallel (different domain/application files)
---
## Parallel Example: Phase 2 (Foundational)
```
Parallel group 1:
T001: PlayerCharacter types in packages/domain/src/player-character-types.ts
T002: Combatant extension in packages/domain/src/types.ts
T003: Domain events in packages/domain/src/events.ts
T004: Port interface in packages/application/src/ports.ts
Sequential after group 1:
T005: Re-export from index files (depends on T001-T004)
T006: Encounter storage rehydration (depends on T002)
```
## Parallel Example: User Story 1
```
Parallel group 1 (after Phase 2):
T007+T008: Domain function + tests in packages/domain/src/
T010+T011: Storage adapter + tests in apps/web/src/persistence/
T013: ColorPalette component
T014: IconGrid component
Sequential after group 1:
T009: Application use case (depends on T007)
T012: React hook (depends on T009, T010)
T015: CreatePlayerModal (depends on T013, T014)
T016: ActionBar button (depends on T015)
T017: App.tsx wiring (depends on T012, T016)
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 2: Foundational types and ports
2. Complete Phase 3: US1 — Create + Persist
3. **STOP and VALIDATE**: Create a player character, reload page, verify persistence
4. This alone delivers value — users can save their party for reuse
### Incremental Delivery
1. Phase 2 → Foundation ready
2. US1 → Create & persist player characters (MVP!)
3. US2 → Search and add to encounters (core value unlocked)
4. US3 → Visual distinction in rows (UX polish)
5. US4 → Edit and delete (full CRUD) — can happen in parallel with US2/US3
6. Phase 7 → Polish and validation
---
## Notes
- [P] tasks = different files, no dependencies
- [Story] label maps task to specific user story for traceability
- Commit after each phase or logical group of tasks
- Run `pnpm check` at each checkpoint to catch regressions early
- The icon identifier → Lucide component mapping will be needed in 3 places (T014, T021, T030) — T032 extracts it to avoid duplication, but initial implementations can inline it