21 Commits

Author SHA1 Message Date
Lukas
07cdd4867a Fix custom combatant form disappearing when dismissing suggestions
All checks were successful
CI / check (push) Successful in 46s
CI / build-image (push) Successful in 18s
The "Add as custom" button and Escape key were clearing the name input
along with the suggestions, preventing the custom fields (Init, AC,
MaxHP) from ever appearing. Now only the suggestions are dismissed,
keeping the typed name intact so the custom combatant form renders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:30:17 +01:00
Lukas
85acb5c185 Migrate icon buttons to Button component and simplify size variants
All checks were successful
CI / check (push) Successful in 48s
CI / build-image (push) Successful in 18s
Replace raw <button> elements with Button variant="ghost" in stat-block
panel, toast, player modals. Add icon-sm size variant (h-6 w-6) for
compact contexts. Consolidate text button sizes into a single default
(h-8 px-3), removing the redundant sm variant. Add size prop to
ConfirmButton for consistent sizing.

Button now has three sizes: default (text), icon, icon-sm.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:22:00 +01:00
Lukas
f9ef64bb00 Unify hover effects via semantic theme tokens
Replace one-off hover colors with hover-neutral/hover-destructive tokens
so all interactive elements respond consistently to theme changes. Fix
hover-neutral-bg token value (was identical to card surface, making hover
invisible on card backgrounds) to a semi-transparent primary tint. Switch
turn nav buttons to outline variant for visible hover feedback. Convert HP
popover damage/heal to plain buttons to avoid ghost variant hover conflict
with tailwind-merge.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:58:01 +01:00
Lukas
bd39808000 Declutter action bars: overflow menu, browse toggle, conditional D20
All checks were successful
CI / check (push) Successful in 48s
CI / build-image (push) Successful in 18s
Top bar stripped to turn navigation only (Prev, round badge, Clear, Next).
Roll All Initiative, Manage Sources, and Bulk Import moved to a new
overflow menu in the bottom bar. Player Characters also moved there.

Browse stat blocks is now an Eye/EyeOff toggle inside the search input
that switches between add mode and browse mode. Add button only appears
when entering a custom creature name. Roll All Initiative button shows
conditionally — only when bestiary creatures lack initiative values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:31:25 +01:00
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
59 changed files with 4683 additions and 452 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,21 +238,56 @@ export function App() {
setSelectedCreatureId(active.creatureId as CreatureId);
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
const isEmpty = encounter.combatants.length === 0;
const showRollAllInitiative = encounter.combatants.some(
(c) => c.creatureId != null && c.initiative == null,
);
return (
<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">
<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>
)}
{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)}
onRollAllInitiative={handleRollAllInitiative}
showRollAllInitiative={showRollAllInitiative}
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
autoFocus
/>
</div>
</div>
) : (
<>
{sourceManagerOpen && (
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
<SourceManager onCacheCleared={refreshCache} />
@@ -206,12 +297,7 @@ export function App() {
{/* 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) => (
{encounter.combatants.map((c, i) => (
<CombatantRow
key={c.id}
ref={i === encounter.activeIndex ? activeRowRef : null}
@@ -234,13 +320,15 @@ export function App() {
c.creatureId ? handleRollInitiative : undefined
}
/>
))
)}
))}
</div>
</div>
{/* Action Bar — fixed at bottom */}
<div className="shrink-0 pb-8">
<div
className={`shrink-0 pb-8${actionBarAnim.settlingClass}`}
onAnimationEnd={actionBarAnim.onSettleEnd}
>
<ActionBar
onAddCombatant={addCombatant}
onAddFromBestiary={handleAddFromBestiary}
@@ -249,8 +337,17 @@ export function App() {
onViewStatBlock={handleViewStatBlock}
onBulkImport={handleBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"}
inputRef={actionBarInputRef}
playerCharacters={playerCharacters}
onAddFromPlayerCharacter={addFromPlayerCharacter}
onManagePlayers={() => setManagementOpen(true)}
onRollAllInitiative={handleRollAllInitiative}
showRollAllInitiative={showRollAllInitiative}
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
/>
</div>
</>
)}
</div>
{/* Pinned Stat Block Panel (left) */}
@@ -321,6 +418,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

@@ -26,8 +26,6 @@ function renderNav(overrides: Partial<Encounter> = {}) {
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>,
);
}
@@ -54,11 +52,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", () => {
@@ -72,8 +70,6 @@ describe("TurnNavigation", () => {
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>,
);
expect(screen.getByText("R2")).toBeInTheDocument();
@@ -88,8 +84,6 @@ describe("TurnNavigation", () => {
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>,
);
expect(screen.getByText("R3")).toBeInTheDocument();
@@ -110,8 +104,6 @@ describe("TurnNavigation", () => {
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>,
);
expect(screen.getByText("Goblin")).toBeInTheDocument();
@@ -129,8 +121,6 @@ describe("TurnNavigation", () => {
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>,
);
expect(screen.getByText("Conjurer")).toBeInTheDocument();
@@ -173,16 +163,6 @@ describe("TurnNavigation", () => {
expect(
screen.getByRole("button", { name: "Next turn" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", {
name: "Roll all initiative",
}),
).toBeInTheDocument();
expect(
screen.getByRole("button", {
name: "Manage cached sources",
}),
).toBeInTheDocument();
});
it("renders a 40-character name without truncation class issues", () => {

View File

@@ -1,8 +1,22 @@
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,
EyeOff,
Import,
Library,
Minus,
Plus,
Users,
} from "lucide-react";
import { type FormEvent, type RefObject, useState } from "react";
import type { SearchResult } from "../hooks/use-bestiary.js";
import { cn } from "../lib/utils.js";
import { D20Icon } from "./d20-icon.js";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
interface QueuedCreature {
result: SearchResult;
@@ -20,12 +34,215 @@ interface ActionBarProps {
onViewStatBlock?: (result: SearchResult) => void;
onBulkImport?: () => void;
bulkImportDisabled?: boolean;
inputRef?: RefObject<HTMLInputElement | null>;
playerCharacters?: readonly PlayerCharacter[];
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
onManagePlayers?: () => void;
onRollAllInitiative?: () => void;
showRollAllInitiative?: boolean;
onOpenSourceManager?: () => void;
autoFocus?: boolean;
}
function creatureKey(r: SearchResult): string {
return `${r.source}:${r.name}`;
}
function AddModeSuggestions({
nameInput,
suggestions,
pcMatches,
suggestionIndex,
queued,
onDismiss,
onClickSuggestion,
onSetSuggestionIndex,
onSetQueued,
onConfirmQueued,
onAddFromPlayerCharacter,
}: {
nameInput: string;
suggestions: SearchResult[];
pcMatches: PlayerCharacter[];
suggestionIndex: number;
queued: QueuedCreature | null;
onDismiss: () => void;
onClickSuggestion: (result: SearchResult) => void;
onSetSuggestionIndex: (i: number) => void;
onSetQueued: (q: QueuedCreature | null) => void;
onConfirmQueued: () => void;
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
}) {
return (
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
<button
type="button"
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-2 text-left text-sm text-accent hover:bg-accent/20"
onMouseDown={(e) => e.preventDefault()}
onClick={onDismiss}
>
<Plus className="h-3.5 w-3.5" />
<span className="flex-1">Add "{nameInput}" as custom</span>
<kbd className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground">
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);
onDismiss();
}}
>
{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>
</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={() => onClickSuggestion(result)}
onMouseEnter={() => onSetSuggestionIndex(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) {
onSetQueued(null);
} else {
onSetQueued({
...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();
onSetQueued({
...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();
onConfirmQueued();
}}
>
<Check className="h-3.5 w-3.5" />
</button>
</>
) : (
result.sourceDisplayName
)}
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
</div>
);
}
function buildOverflowItems(opts: {
onManagePlayers?: () => void;
onOpenSourceManager?: () => void;
bestiaryLoaded: boolean;
onBulkImport?: () => void;
bulkImportDisabled?: boolean;
}): OverflowMenuItem[] {
const items: OverflowMenuItem[] = [];
if (opts.onManagePlayers) {
items.push({
icon: <Users className="h-4 w-4" />,
label: "Player Characters",
onClick: opts.onManagePlayers,
});
}
if (opts.onOpenSourceManager) {
items.push({
icon: <Library className="h-4 w-4" />,
label: "Manage Sources",
onClick: opts.onOpenSourceManager,
});
}
if (opts.bestiaryLoaded && opts.onBulkImport) {
items.push({
icon: <Import className="h-4 w-4" />,
label: "Bulk Import",
onClick: opts.onBulkImport,
disabled: opts.bulkImportDisabled,
});
}
return items;
}
export function ActionBar({
onAddCombatant,
onAddFromBestiary,
@@ -34,22 +251,24 @@ export function ActionBar({
onViewStatBlock,
onBulkImport,
bulkImportDisabled,
inputRef,
playerCharacters,
onAddFromPlayerCharacter,
onManagePlayers,
onRollAllInitiative,
showRollAllInitiative,
onOpenSourceManager,
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("");
const [customAc, setCustomAc] = useState("");
const [customMaxHp, setCustomMaxHp] = useState("");
// Stat block viewer: separate dropdown
const [viewerOpen, setViewerOpen] = useState(false);
const [viewerQuery, setViewerQuery] = useState("");
const [viewerResults, setViewerResults] = useState<SearchResult[]>([]);
const [viewerIndex, setViewerIndex] = useState(-1);
const viewerRef = useRef<HTMLDivElement>(null);
const viewerInputRef = useRef<HTMLInputElement>(null);
const [browseMode, setBrowseMode] = useState(false);
const clearCustomFields = () => {
setCustomInit("");
@@ -57,15 +276,27 @@ export function ActionBar({
setCustomMaxHp("");
};
const clearInput = () => {
setNameInput("");
setSuggestions([]);
setPcMatches([]);
setQueued(null);
setSuggestionIndex(-1);
};
const dismissSuggestions = () => {
setSuggestions([]);
setPcMatches([]);
setQueued(null);
setSuggestionIndex(-1);
};
const confirmQueued = () => {
if (!queued) return;
for (let i = 0; i < queued.count; i++) {
onAddFromBestiary(queued.result);
}
setQueued(null);
setNameInput("");
setSuggestions([]);
setSuggestionIndex(-1);
clearInput();
};
const parseNum = (v: string): number | undefined => {
@@ -76,6 +307,7 @@ export function ActionBar({
const handleAdd = (e: FormEvent) => {
e.preventDefault();
if (browseMode) return;
if (queued) {
confirmQueued();
return;
@@ -91,20 +323,32 @@ export function ActionBar({
onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
setNameInput("");
setSuggestions([]);
setPcMatches([]);
clearCustomFields();
};
const handleNameChange = (value: string) => {
setNameInput(value);
setSuggestionIndex(-1);
const handleBrowseSearch = (value: string) => {
setSuggestions(value.length >= 2 ? bestiarySearch(value) : []);
};
const handleAddSearch = (value: string) => {
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) {
@@ -116,6 +360,16 @@ export function ActionBar({
}
};
const handleNameChange = (value: string) => {
setNameInput(value);
setSuggestionIndex(-1);
if (browseMode) {
handleBrowseSearch(value);
} else {
handleAddSearch(value);
}
};
const handleClickSuggestion = (result: SearchResult) => {
const key = creatureKey(result);
if (queued && creatureKey(queued.result) === key) {
@@ -133,8 +387,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();
@@ -146,73 +402,50 @@ export function ActionBar({
e.preventDefault();
handleEnter();
} else if (e.key === "Escape") {
setQueued(null);
setSuggestionIndex(-1);
setSuggestions([]);
dismissSuggestions();
}
};
// Stat block viewer dropdown handlers
const openViewer = () => {
setViewerOpen(true);
setViewerQuery("");
setViewerResults([]);
setViewerIndex(-1);
requestAnimationFrame(() => viewerInputRef.current?.focus());
};
const closeViewer = () => {
setViewerOpen(false);
setViewerQuery("");
setViewerResults([]);
setViewerIndex(-1);
};
const handleViewerQueryChange = (value: string) => {
setViewerQuery(value);
setViewerIndex(-1);
if (value.length >= 2) {
setViewerResults(bestiarySearch(value));
} else {
setViewerResults([]);
}
};
const handleViewerSelect = (result: SearchResult) => {
onViewStatBlock?.(result);
closeViewer();
};
const handleViewerKeyDown = (e: React.KeyboardEvent) => {
const handleBrowseKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
closeViewer();
setBrowseMode(false);
clearInput();
return;
}
if (viewerResults.length === 0) return;
if (suggestions.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setViewerIndex((i) => (i < viewerResults.length - 1 ? i + 1 : 0));
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setViewerIndex((i) => (i > 0 ? i - 1 : viewerResults.length - 1));
} else if (e.key === "Enter" && viewerIndex >= 0) {
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
} else if (e.key === "Enter" && suggestionIndex >= 0) {
e.preventDefault();
handleViewerSelect(viewerResults[viewerIndex]);
onViewStatBlock?.(suggestions[suggestionIndex]);
setBrowseMode(false);
clearInput();
}
};
// Close viewer on outside click
useEffect(() => {
if (!viewerOpen) return;
function handleClickOutside(e: MouseEvent) {
if (viewerRef.current && !viewerRef.current.contains(e.target as Node)) {
closeViewer();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [viewerOpen]);
const handleBrowseSelect = (result: SearchResult) => {
onViewStatBlock?.(result);
setBrowseMode(false);
clearInput();
};
const toggleBrowseMode = () => {
setBrowseMode((m) => !m);
clearInput();
clearCustomFields();
};
const overflowItems = buildOverflowItems({
onManagePlayers,
onOpenSourceManager,
bestiaryLoaded,
onBulkImport,
bulkImportDisabled,
});
return (
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
@@ -220,101 +453,85 @@ export function ActionBar({
onSubmit={handleAdd}
className="relative flex flex-1 items-center gap-2"
>
<div className="relative flex-1">
<div className="flex-1">
<div className="relative max-w-xs">
<Input
ref={inputRef}
type="text"
value={nameInput}
onChange={(e) => handleNameChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search creatures to add..."
className="max-w-xs"
onKeyDown={browseMode ? handleBrowseKeyDown : handleKeyDown}
placeholder={
browseMode ? "Search stat blocks..." : "+ Add combatants"
}
className="pr-8"
autoFocus={autoFocus}
/>
{suggestions.length > 0 && (
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
{bestiaryLoaded && onViewStatBlock && (
<button
type="button"
tabIndex={-1}
className={cn(
"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
browseMode && "text-accent",
)}
onClick={toggleBrowseMode}
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
aria-label={
browseMode ? "Switch to add mode" : "Browse stat blocks"
}
>
{browseMode ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
)}
{browseMode && suggestions.length > 0 && (
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
<ul className="max-h-48 overflow-y-auto py-1">
{suggestions.map((result, i) => {
const key = creatureKey(result);
const isQueued =
queued !== null && creatureKey(queued.result) === key;
return (
<li key={key}>
{suggestions.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 ${
isQueued
? "bg-accent/30 text-foreground"
: i === suggestionIndex
i === suggestionIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleClickSuggestion(result)}
onClick={() => handleBrowseSelect(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 className="text-xs text-muted-foreground">
{result.sourceDisplayName}
</span>
</button>
</li>
);
})}
))}
</ul>
</div>
)}
{!browseMode && hasSuggestions && (
<AddModeSuggestions
nameInput={nameInput}
suggestions={suggestions}
pcMatches={pcMatches}
suggestionIndex={suggestionIndex}
queued={queued}
onDismiss={dismissSuggestions}
onClickSuggestion={handleClickSuggestion}
onSetSuggestionIndex={setSuggestionIndex}
onSetQueued={setQueued}
onConfirmQueued={confirmQueued}
onAddFromPlayerCharacter={onAddFromPlayerCharacter}
/>
)}
</div>
{nameInput.length >= 2 && suggestions.length === 0 && (
</div>
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
<div className="flex items-center gap-2">
<Input
type="text"
@@ -342,75 +559,23 @@ export function ActionBar({
/>
</div>
)}
<Button type="submit" size="sm">
Add
</Button>
{bestiaryLoaded && onViewStatBlock && (
<div ref={viewerRef} className="relative">
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
<Button type="submit">Add</Button>
)}
{showRollAllInitiative && onRollAllInitiative && (
<Button
type="button"
size="sm"
size="icon"
variant="ghost"
onClick={() => (viewerOpen ? closeViewer() : openViewer())}
className="text-muted-foreground hover:text-hover-action"
onClick={onRollAllInitiative}
title="Roll all initiative"
aria-label="Roll all initiative"
>
<Eye className="h-4 w-4" />
</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
</div>
)}
</div>
)}
</div>
)}
{bestiaryLoaded && onBulkImport && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={onBulkImport}
disabled={bulkImportDisabled}
>
<Import className="h-4 w-4" />
<D20Icon className="h-6 w-6" />
</Button>
)}
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
</form>
</div>
);

View File

@@ -28,9 +28,7 @@ export function BulkImportPrompt({
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-sm text-green-400">
All sources loaded
</div>
<Button size="sm" onClick={onDone}>
Done
</Button>
<Button onClick={onDone}>Done</Button>
</div>
);
}
@@ -42,9 +40,7 @@ export function BulkImportPrompt({
Loaded {importState.completed}/{importState.total} sources (
{importState.failed} failed)
</div>
<Button size="sm" onClick={onDone}>
Done
</Button>
<Button onClick={onDone}>Done</Button>
</div>
);
}
@@ -103,11 +99,7 @@ export function BulkImportPrompt({
/>
</div>
<Button
size="sm"
onClick={() => onStartImport(baseUrl)}
disabled={isDisabled}
>
<Button onClick={() => onStartImport(baseUrl)} disabled={isDisabled}>
Load All
</Button>
</div>

View File

@@ -0,0 +1,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,187 @@
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
variant="ghost"
size="icon"
onClick={onClose}
className="text-muted-foreground"
>
<X size={20} />
</Button>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<span className="mb-1 block text-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

@@ -6,7 +6,6 @@ import {
useRef,
useState,
} from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
interface HpAdjustPopoverProps {
@@ -109,30 +108,26 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
}}
onKeyDown={handleKeyDown}
/>
<Button
<button
type="button"
variant="ghost"
size="icon"
disabled={!isValid}
className="h-7 w-7 shrink-0 text-red-400 hover:bg-red-950 hover:text-red-300"
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-red-400 transition-colors hover:bg-red-950 hover:text-red-300 disabled:pointer-events-none disabled:opacity-50"
onClick={() => applyDelta(-1)}
title="Apply damage"
aria-label="Apply damage"
>
<Sword size={14} />
</Button>
<Button
</button>
<button
type="button"
variant="ghost"
size="icon"
disabled={!isValid}
className="h-7 w-7 shrink-0 text-emerald-400 hover:bg-emerald-950 hover:text-emerald-300"
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-emerald-400 transition-colors hover:bg-emerald-950 hover:text-emerald-300 disabled:pointer-events-none disabled:opacity-50"
onClick={() => applyDelta(1)}
title="Apply healing"
aria-label="Apply healing"
>
<Heart size={14} />
</Button>
</button>
</div>
</div>
);

View File

@@ -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,126 @@
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
variant="ghost"
size="icon"
onClick={onClose}
className="text-muted-foreground"
>
<X size={20} />
</Button>
</div>
{characters.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-8 text-center">
<p className="text-muted-foreground">No player characters yet</p>
<Button onClick={onCreate}>
<Plus size={16} />
Create your first player character
</Button>
</div>
) : (
<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-hover-neutral-bg"
>
{Icon && (
<Icon size={18} style={{ color }} className="shrink-0" />
)}
<span className="flex-1 truncate text-sm text-foreground">
{pc.name}
</span>
<span className="text-xs tabular-nums text-muted-foreground">
AC {pc.ac}
</span>
<span className="text-xs tabular-nums text-muted-foreground">
HP {pc.maxHp}
</span>
<Button
variant="ghost"
size="icon-sm"
onClick={() => onEdit(pc)}
className="text-muted-foreground"
title="Edit"
>
<Pencil size={14} />
</Button>
<ConfirmButton
icon={<Trash2 size={14} />}
label="Delete player character"
onConfirm={() => onDelete(pc.id)}
size="icon-sm"
className="text-muted-foreground"
/>
</div>
);
})}
<div className="mt-2 flex justify-end">
<Button onClick={onCreate} variant="ghost">
<Plus size={16} />
Add
</Button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -88,11 +88,7 @@ export function SourceFetchPrompt({
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={handleFetch}
disabled={status === "fetching" || !url}
>
<Button onClick={handleFetch} disabled={status === "fetching" || !url}>
{status === "fetching" ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
@@ -104,7 +100,6 @@ export function SourceFetchPrompt({
<span className="text-xs text-muted-foreground">or</span>
<Button
size="sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={status === "fetching"}

View File

@@ -47,7 +47,11 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
<span className="text-sm font-semibold text-foreground">
Cached Sources
</span>
<Button size="sm" variant="outline" onClick={handleClearAll}>
<Button
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 +73,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

@@ -8,6 +8,7 @@ import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
import { BulkImportPrompt } from "./bulk-import-prompt.js";
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
import { StatBlock } from "./stat-block.js";
import { Button } from "./ui/button.js";
interface StatBlockPanelProps {
creatureId: CreatureId | null;
@@ -81,36 +82,39 @@ function PanelHeader({
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex items-center gap-1">
{panelRole === "browse" && (
<button
type="button"
<Button
variant="ghost"
size="icon-sm"
onClick={onToggleFold}
className="text-muted-foreground hover:text-hover-neutral"
className="text-muted-foreground"
aria-label="Fold stat block panel"
>
<PanelRightClose className="h-4 w-4" />
</button>
</Button>
)}
</div>
<div className="flex items-center gap-1">
{panelRole === "browse" && showPinButton && (
<button
type="button"
<Button
variant="ghost"
size="icon-sm"
onClick={onPin}
className="text-muted-foreground hover:text-hover-neutral"
className="text-muted-foreground"
aria-label="Pin creature"
>
<Pin className="h-4 w-4" />
</button>
</Button>
)}
{panelRole === "pinned" && (
<button
type="button"
<Button
variant="ghost"
size="icon-sm"
onClick={onUnpin}
className="text-muted-foreground hover:text-hover-neutral"
className="text-muted-foreground"
aria-label="Unpin creature"
>
<PinOff className="h-4 w-4" />
</button>
</Button>
)}
</div>
</div>
@@ -195,14 +199,15 @@ function MobileDrawer({
{...handlers}
>
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<button
type="button"
<Button
variant="ghost"
size="icon-sm"
onClick={onDismiss}
className="text-muted-foreground hover:text-hover-neutral"
className="text-muted-foreground"
aria-label="Fold stat block panel"
>
<PanelRightClose className="h-4 w-4" />
</button>
</Button>
</div>
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
{children}

View File

@@ -1,6 +1,7 @@
import { X } from "lucide-react";
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { Button } from "./ui/button.js";
interface ToastProps {
message: string;
@@ -33,13 +34,14 @@ export function Toast({
/>
</div>
)}
<button
type="button"
<Button
variant="ghost"
size="icon-sm"
onClick={onDismiss}
className="text-muted-foreground hover:text-hover-neutral"
className="text-muted-foreground"
>
<X className="h-3 w-3" />
</button>
</Button>
</div>
</div>,
document.body,

View File

@@ -1,6 +1,5 @@
import type { Encounter } from "@initiative/domain";
import { Settings, StepBack, StepForward, Trash2 } from "lucide-react";
import { D20Icon } from "./d20-icon";
import { StepBack, StepForward, Trash2 } from "lucide-react";
import { Button } from "./ui/button";
import { ConfirmButton } from "./ui/confirm-button";
@@ -9,8 +8,6 @@ interface TurnNavigationProps {
onAdvanceTurn: () => void;
onRetreatTurn: () => void;
onClearEncounter: () => void;
onRollAllInitiative: () => void;
onOpenSourceManager: () => void;
}
export function TurnNavigation({
@@ -18,8 +15,6 @@ export function TurnNavigation({
onAdvanceTurn,
onRetreatTurn,
onClearEncounter,
onRollAllInitiative,
onOpenSourceManager,
}: TurnNavigationProps) {
const hasCombatants = encounter.combatants.length > 0;
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
@@ -27,11 +22,9 @@ 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"
@@ -39,55 +32,29 @@ export function TurnNavigation({
>
<StepBack className="h-5 w-5" />
</Button>
<span className="rounded-full bg-muted text-foreground text-sm px-2 py-0.5 font-semibold">
<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>
)}
</div>
<div className="flex flex-shrink-0 items-center gap-3">
<div className="flex items-center gap-0">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-hover-action"
onClick={onRollAllInitiative}
title="Roll all initiative"
aria-label="Roll all initiative"
>
<D20Icon className="h-6 w-6" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-hover-neutral"
onClick={onOpenSourceManager}
title="Manage cached sources"
aria-label="Manage cached sources"
>
<Settings 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

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

View File

@@ -13,6 +13,7 @@ interface ConfirmButtonProps {
readonly onConfirm: () => void;
readonly icon: ReactElement;
readonly label: string;
readonly size?: "icon" | "icon-sm";
readonly className?: string;
readonly disabled?: boolean;
}
@@ -23,6 +24,7 @@ export function ConfirmButton({
onConfirm,
icon,
label,
size = "icon",
className,
disabled,
}: ConfirmButtonProps) {
@@ -94,11 +96,12 @@ export function ConfirmButton({
<div ref={wrapperRef} className="inline-flex">
<Button
variant="ghost"
size="icon"
size={size}
className={cn(
className,
isConfirming &&
"bg-destructive text-primary-foreground rounded-md animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground",
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

@@ -0,0 +1,72 @@
import { EllipsisVertical } from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { Button } from "./button";
export interface OverflowMenuItem {
readonly icon: ReactNode;
readonly label: string;
readonly onClick: () => void;
readonly disabled?: boolean;
}
interface OverflowMenuProps {
readonly items: readonly OverflowMenuItem[];
}
export function OverflowMenu({ items }: OverflowMenuProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleMouseDown(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
return (
<div ref={ref} className="relative">
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-hover-neutral"
onClick={() => setOpen((o) => !o)}
aria-label="More actions"
title="More actions"
>
<EllipsisVertical className="h-5 w-5" />
</Button>
{open && (
<div className="absolute bottom-full right-0 z-50 mb-1 min-w-48 rounded-md border border-border bg-card py-1 shadow-lg">
{items.map((item) => (
<button
key={item.label}
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-hover-neutral-bg disabled:pointer-events-none disabled:opacity-50"
disabled={item.disabled}
onClick={() => {
item.onClick();
setOpen(false);
}}
>
{item.icon}
{item.label}
</button>
))}
</div>
)}
</div>
);
}

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

@@ -16,7 +16,7 @@
--color-hover-neutral: var(--color-primary);
--color-hover-action: var(--color-primary);
--color-hover-destructive: var(--color-destructive);
--color-hover-neutral-bg: var(--color-card);
--color-hover-neutral-bg: oklch(0.623 0.214 259 / 0.15);
--color-hover-action-bg: var(--color-muted);
--color-hover-destructive-bg: transparent;
--radius-sm: 0.25rem;
@@ -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": {

View File

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

View File

@@ -0,0 +1,176 @@
---
date: "2026-03-13T14:39:15.661886+00:00"
git_commit: 75778884bd1be7d135b2f5ea9b8a8e77a0149f7b
branch: main
topic: "Action Bars Setup — Top Bar and Bottom Bar Buttons"
tags: [research, codebase, action-bar, turn-navigation, layout, buttons]
status: complete
---
# Research: Action Bars Setup — Top Bar and Bottom Bar Buttons
## Research Question
How are the top and bottom action bars set up, what buttons do they contain, and how are their actions wired?
## Summary
The application has two primary bar components that frame the encounter tracker UI:
1. **Top bar**`TurnNavigation` (`turn-navigation.tsx`) — turn controls, round/combatant display, and encounter-wide actions.
2. **Bottom bar**`ActionBar` (`action-bar.tsx`) — combatant input, bestiary search, stat block browsing, bulk import, and player character management.
Both bars share the same visual container styling (`flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3`). They are laid out in `App.tsx` within a flex column, with a scrollable combatant list between them. When the encounter is empty, only the ActionBar is shown (centered in the viewport); the TurnNavigation appears with an animation when the first combatant is added.
## Detailed Findings
### Layout Structure (`App.tsx:243-344`)
The bars live inside a `max-w-2xl` centered column:
```
┌──────────────────────────────────┐
│ TurnNavigation (pt-8, shrink-0) │ ← top bar, conditionally shown
├──────────────────────────────────┤
│ SourceManager (optional inline) │ ← toggled by Library button in top bar
├──────────────────────────────────┤
│ Combatant list (flex-1, │ ← scrollable
│ overflow-y-auto) │
├──────────────────────────────────┤
│ ActionBar (pb-8, shrink-0) │ ← bottom bar
└──────────────────────────────────┘
```
**Empty state**: When `encounter.combatants.length === 0`, the top bar is hidden and the ActionBar is vertically centered in a `flex items-center justify-center` wrapper with `pb-[15%]` offset. It receives `autoFocus` in this state.
**Animation** (`useActionBarAnimation`, `App.tsx:30-66`): Manages transitions between empty and populated states:
- Empty → populated: ActionBar plays `animate-settle-to-bottom`, TurnNavigation plays `animate-slide-down-in`.
- Populated → empty: ActionBar plays `animate-rise-to-center`, TurnNavigation plays `animate-slide-up-out` (with `absolute` positioning during exit).
The `showTopBar` flag is `true` when either combatants exist or the top bar exit animation is still running.
### Top Bar — TurnNavigation (`turn-navigation.tsx`)
**Props interface** (`turn-navigation.tsx:7-14`):
- `encounter: Encounter` — full encounter state
- `onAdvanceTurn`, `onRetreatTurn` — turn navigation callbacks
- `onClearEncounter` — destructive clear with confirmation
- `onRollAllInitiative` — rolls initiative for all combatants
- `onOpenSourceManager` — toggles source manager panel
**Layout**: LeftCenterRight structure:
```
[ ◀ Prev ] | [ R1 Active Combatant Name ] | [ 🎲 📚 🗑 ] [ Next ▶ ]
```
**Buttons (left to right)**:
| # | Icon | Component | Variant | Action | Disabled when |
|---|------|-----------|---------|--------|---------------|
| 1 | `StepBack` | `Button` | default | `onRetreatTurn` | No combatants OR at round 1 index 0 |
| 2 | `D20Icon` | `Button` | ghost | `onRollAllInitiative` | Never |
| 3 | `Library` | `Button` | ghost | `onOpenSourceManager` | Never |
| 4 | `Trash2` | `ConfirmButton` | — | `onClearEncounter` | No combatants |
| 5 | `StepForward` | `Button` | default | `onAdvanceTurn` | No combatants |
**Center section** (`turn-navigation.tsx:40-49`): Displays a round badge (`R{n}` in a `rounded-full bg-muted` span) and the active combatant's name (truncated). Falls back to "No combatants" in muted text.
**Button grouping**: Buttons 2-4 are grouped in a `gap-0` div (tight spacing), while button 5 (Next) is separated by the outer `gap-3`.
**Wiring in App.tsx** (`App.tsx:251-258`):
- `onAdvanceTurn``advanceTurn` from `useEncounter()`
- `onRetreatTurn``retreatTurn` from `useEncounter()`
- `onClearEncounter``clearEncounter` from `useEncounter()`
- `onRollAllInitiative``handleRollAllInitiative` → calls `rollAllInitiativeUseCase(makeStore(), rollDice, getCreature)`
- `onOpenSourceManager` → toggles `sourceManagerOpen` state
### Bottom Bar — ActionBar (`action-bar.tsx`)
**Props interface** (`action-bar.tsx:20-36`):
- `onAddCombatant` — adds custom combatant with optional init/AC/maxHP
- `onAddFromBestiary` — adds creature from search result
- `bestiarySearch` — search function returning `SearchResult[]`
- `bestiaryLoaded` — whether bestiary index is loaded
- `onViewStatBlock` — opens stat block panel for a creature
- `onBulkImport` — triggers bulk source import mode
- `bulkImportDisabled` — disables import button during loading
- `inputRef` — external ref to the name input
- `playerCharacters` — list of player characters for quick-add
- `onAddFromPlayerCharacter` — adds a player character to encounter
- `onManagePlayers` — opens player management modal
- `autoFocus` — auto-focuses input (used in empty state)
**Layout**: Form with input, contextual fields, submit button, and action icons:
```
[ + Add combatants... ] [ Init ] [ AC ] [ MaxHP ] [ Add ] [ 👥 👁 📥 ]
```
The Init/AC/MaxHP fields only appear when the input has 2+ characters and no bestiary suggestions are showing.
**Buttons (left to right)**:
| # | Icon | Component | Variant | Action | Condition |
|---|------|-----------|---------|--------|-----------|
| 1 | — | `Button` | sm | Form submit → `handleAdd` | Always shown |
| 2 | `Users` | `Button` | ghost | `onManagePlayers` | Only if `onManagePlayers` provided |
| 3 | `Eye` | `Button` | ghost | Toggle stat block viewer dropdown | Only if `bestiaryLoaded && onViewStatBlock` |
| 4 | `Import` | `Button` | ghost | `onBulkImport` | Only if `bestiaryLoaded && onBulkImport` |
**Button grouping**: Buttons 2-4 are grouped in a `gap-0` div, mirroring the top bar's icon button grouping.
**Suggestion dropdown** (`action-bar.tsx:267-410`): Opens above the input when 2+ chars are typed and results exist. Contains:
- A "Add as custom" escape row at the top (with `Esc` keyboard hint)
- **Players section**: Lists matching player characters with colored icons; clicking adds them directly via `onAddFromPlayerCharacter`
- **Bestiary section**: Lists search results; clicking queues a creature. Queued creatures show:
- `Minus` button — decrements count (removes queue at 0)
- Count badge — current queued count
- `Plus` button — increments count
- `Check` button — confirms and adds all queued copies
**Stat block viewer dropdown** (`action-bar.tsx:470-513`): A separate search dropdown anchored to the Eye button. Has its own input, search results, and keyboard navigation. Selecting a result calls `onViewStatBlock`.
**Keyboard handling** (`action-bar.tsx:168-186`):
- Arrow Up/Down — navigate suggestion list
- Enter — queue selected suggestion or confirm queued batch
- Escape — clear suggestions and queue
**Wiring in App.tsx** (`App.tsx:269-282` and `328-340`):
- `onAddCombatant``addCombatant` from `useEncounter()`
- `onAddFromBestiary``handleAddFromBestiary``addFromBestiary` from `useEncounter()`
- `bestiarySearch``search` from `useBestiary()`
- `onViewStatBlock``handleViewStatBlock` → constructs `CreatureId` and sets `selectedCreatureId`
- `onBulkImport``handleBulkImport` → sets `bulkImportMode` and clears selection
- `onAddFromPlayerCharacter``addFromPlayerCharacter` from `useEncounter()`
- `onManagePlayers` → opens `managementOpen` state (shows `PlayerManagement` modal)
### Shared UI Primitives
**`Button`** (`ui/button.tsx`): CVA-based component with variants (`default`, `outline`, `ghost`) and sizes (`default`, `sm`, `icon`). Both bars use `size="icon"` with `variant="ghost"` for their icon button clusters, and `size="icon"` with default variant for the primary navigation buttons (Prev/Next in top bar).
**`ConfirmButton`** (`ui/confirm-button.tsx`): Two-click destructive action button. First click shows a red pulsing confirmation state with a Check icon; second click fires `onConfirm`. Auto-reverts after 5 seconds. Supports Escape and click-outside cancellation. Used for Clear Encounter in the top bar.
### Hover Color Convention
Both bars use consistent hover color classes on their ghost icon buttons:
- `hover:text-hover-action` — used on the D20 (roll initiative) button, suggesting an action/accent color
- `hover:text-hover-neutral` — used on Library, Users, Eye, Import buttons, suggesting a neutral/informational color
## Code References
- `apps/web/src/components/turn-navigation.tsx` — Top bar component (93 lines)
- `apps/web/src/components/action-bar.tsx` — Bottom bar component (533 lines)
- `apps/web/src/App.tsx:30-66``useActionBarAnimation` hook for bar transitions
- `apps/web/src/App.tsx:243-344` — Layout structure with both bars
- `apps/web/src/components/ui/button.tsx` — Shared Button component
- `apps/web/src/components/ui/confirm-button.tsx` — Two-step confirmation button
- `apps/web/src/components/d20-icon.tsx` — Custom D20 dice SVG icon
## Architecture Documentation
The bars follow the app's adapter-layer convention: they are pure presentational React components that receive all behavior via callback props. No business logic lives in either bar — they delegate to handlers defined in `App.tsx`, which in turn call use-case functions from the application layer or manipulate local UI state.
Both bars are rendered twice in `App.tsx` (once in the empty-state branch, once in the populated branch) rather than being conditionally repositioned, which simplifies the animation logic.
The `ActionBar` is the more complex of the two, managing multiple pieces of local state (input value, suggestions, queued creatures, custom fields, stat block viewer) while `TurnNavigation` is fully stateless — all its data comes from the `encounter` prop.

View File

@@ -0,0 +1,188 @@
---
date: "2026-03-13T15:35:07.699570+00:00"
git_commit: bd398080008349b47726d0016f4b03587f453833
branch: main
topic: "CSS class usage, button categorization, and hover effects across all components"
tags: [research, codebase, css, tailwind, buttons, hover, ui]
status: complete
---
# Research: CSS Class Usage, Button Categorization, and Hover Effects
## Research Question
How are CSS classes used across all components? How are buttons categorized — are there primary and secondary buttons? What hover effects exist, and are they unified?
## Summary
The project uses **Tailwind CSS v4** with a custom dark theme defined in `index.css` via `@theme`. All class merging goes through a `cn()` utility (clsx + tailwind-merge). Buttons are built on a shared `Button` component using **class-variance-authority (CVA)** with three variants: **default** (primary), **outline**, and **ghost**. Hover effects are partially unified through semantic color tokens (`hover-neutral`, `hover-action`, `hover-destructive`) defined in the theme, but several components use **one-off hardcoded hover colors** that bypass the token system.
## Detailed Findings
### Theme System (`index.css`)
All colors are defined as CSS custom properties via Tailwind v4's `@theme` directive (`index.css:3-26`):
| Token | Value | Purpose |
|---|---|---|
| `--color-background` | `#0f172a` | Page background |
| `--color-foreground` | `#e2e8f0` | Default text |
| `--color-muted` | `#64748b` | Subdued elements |
| `--color-muted-foreground` | `#94a3b8` | Secondary text |
| `--color-card` | `#1e293b` | Card/panel surfaces |
| `--color-border` | `#334155` | Borders |
| `--color-primary` | `#3b82f6` | Primary actions (blue) |
| `--color-accent` | `#3b82f6` | Accent (same as primary) |
| `--color-destructive` | `#ef4444` | Destructive actions (red) |
**Hover tokens** (semantic layer for hover states):
| Token | Resolves to | Usage |
|---|---|---|
| `hover-neutral` | `primary` (blue) | Text color on neutral hover |
| `hover-action` | `primary` (blue) | Text color on action hover |
| `hover-destructive` | `destructive` (red) | Text color on destructive hover |
| `hover-neutral-bg` | `card` (slate) | Background on neutral hover |
| `hover-action-bg` | `muted` | Background on action hover |
| `hover-destructive-bg` | `transparent` | Background on destructive hover |
### Button Component (`components/ui/button.tsx`)
Uses CVA with three variants and three sizes:
**Variants:**
| Variant | Base styles | Hover |
|---|---|---|
| `default` (primary) | `bg-primary text-primary-foreground` | `hover:bg-primary/90` |
| `outline` | `border border-border bg-transparent` | `hover:bg-hover-neutral-bg hover:text-hover-neutral` |
| `ghost` | (no background/border) | `hover:bg-hover-neutral-bg hover:text-hover-neutral` |
**Sizes:**
| Size | Classes |
|---|---|
| `default` | `h-9 px-4 py-2` |
| `sm` | `h-8 px-3 text-xs` |
| `icon` | `h-8 w-8` |
All variants share: `rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50`.
There is **no "secondary" variant** — the outline variant is the closest equivalent.
### Composite Button Components
**ConfirmButton** (`components/ui/confirm-button.tsx`):
- Wraps `Button variant="ghost" size="icon"`
- Default state: `hover:text-hover-destructive` (uses token)
- Confirming state: `bg-destructive text-primary-foreground animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground`
**OverflowMenu** (`components/ui/overflow-menu.tsx`):
- Trigger: `Button variant="ghost" size="icon"` with `text-muted-foreground hover:text-hover-neutral`
- Menu items: raw `<button>` elements with `hover:bg-muted/20` (**not using the token system**)
### Button Usage Across Components
| Component | Button type | Variant/Style |
|---|---|---|
| `action-bar.tsx:556` | `<Button type="submit">` | default (primary) — "Add" |
| `action-bar.tsx:561` | `<Button type="button">` | default (primary) — "Roll all" |
| `turn-navigation.tsx:25,54` | `<Button size="icon">` | default — prev/next turn |
| `turn-navigation.tsx:47` | `<ConfirmButton>` | ghost+destructive — clear encounter |
| `source-fetch-prompt.tsx:91` | `<Button size="sm">` | default — "Load" |
| `source-fetch-prompt.tsx:106` | `<Button size="sm" variant="outline">` | outline — "Upload file" |
| `bulk-import-prompt.tsx:31,45,106` | `<Button size="sm">` | default — "Done"/"Load All" |
| `source-manager.tsx:50` | `<Button size="sm" variant="outline">` | outline — "Clear all" |
| `hp-adjust-popover.tsx:112` | `<Button variant="ghost" size="icon">` | ghost + custom red — damage |
| `hp-adjust-popover.tsx:124` | `<Button variant="ghost" size="icon">` | ghost + custom green — heal |
| `player-management.tsx:67` | `<Button>` | default — "Create first player" |
| `player-management.tsx:113` | `<Button variant="ghost">` | ghost — "Add player" |
| `create-player-modal.tsx:177` | `<Button variant="ghost">` | ghost — "Cancel" |
| `create-player-modal.tsx:180` | `<Button type="submit">` | default — "Save"/"Create" |
| `combatant-row.tsx:625` | `<ConfirmButton>` | ghost+destructive — remove combatant |
**Raw `<button>` elements** (not using the Button component):
- `action-bar.tsx` — suggestion items, count increment/decrement, browse toggle, custom add (all inline-styled)
- `combatant-row.tsx` — editable name, HP display, AC, initiative, concentration toggle
- `stat-block-panel.tsx` — fold/close/pin/unpin buttons
- `condition-picker.tsx` — condition items
- `condition-tags.tsx` — condition tags, add condition button
- `toast.tsx` — dismiss button
- `player-management.tsx` — close modal, edit player
- `create-player-modal.tsx` — close modal
- `color-palette.tsx` — color swatches
- `icon-grid.tsx` — icon options
### Hover Effects Inventory
**Using semantic tokens (unified):**
| Hover class | Meaning | Used in |
|---|---|---|
| `hover:bg-hover-neutral-bg` | Neutral background highlight | button.tsx (outline/ghost), action-bar.tsx, condition-picker.tsx, condition-tags.tsx |
| `hover:text-hover-neutral` | Text turns primary blue | button.tsx (outline/ghost), action-bar.tsx, combatant-row.tsx, stat-block-panel.tsx, ac-shield.tsx, toast.tsx, overflow-menu.tsx, condition-tags.tsx |
| `hover:text-hover-action` | Action text (same as neutral) | action-bar.tsx (overflow trigger) |
| `hover:text-hover-destructive` | Destructive text turns red | confirm-button.tsx, source-manager.tsx |
| `hover:bg-hover-destructive-bg` | Destructive background (transparent) | source-manager.tsx |
**One-off / hardcoded hover colors (NOT using tokens):**
| Hover class | Used in | Context |
|---|---|---|
| `hover:bg-primary/90` | button.tsx (default variant) | Primary button darken |
| `hover:bg-accent/20` | action-bar.tsx | Suggestion highlight, custom add |
| `hover:bg-accent/40` | action-bar.tsx | Count +/- buttons, confirm queued |
| `hover:bg-muted/20` | overflow-menu.tsx | Menu item highlight |
| `hover:bg-red-950` | hp-adjust-popover.tsx | Damage button |
| `hover:text-red-300` | hp-adjust-popover.tsx | Damage button text |
| `hover:bg-emerald-950` | hp-adjust-popover.tsx | Heal button |
| `hover:text-emerald-300` | hp-adjust-popover.tsx | Heal button text |
| `hover:text-foreground` | player-management.tsx, create-player-modal.tsx, icon-grid.tsx | Close/edit buttons |
| `hover:bg-background/50` | player-management.tsx | Player row hover |
| `hover:bg-card` | icon-grid.tsx | Icon option hover |
| `hover:border-hover-destructive` | source-manager.tsx | Clear all button border |
| `hover:scale-110` | color-palette.tsx | Color swatch enlarge |
| `hover:bg-destructive` | confirm-button.tsx (confirming state) | Maintain red bg on hover |
| `hover:text-primary-foreground` | confirm-button.tsx (confirming state) | Maintain white text on hover |
### Hover unification assessment
The hover token system (`hover-neutral`, `hover-action`, `hover-destructive`) provides a consistent pattern for the most common interactions. The `Button` component's outline and ghost variants use these tokens, and many inline buttons in action-bar, combatant-row, stat-block-panel, and condition components also use them.
However, there are notable gaps:
1. **HP adjust popover** uses hardcoded red/green colors (`red-950`, `emerald-950`) instead of tokens
2. **Overflow menu items** use `hover:bg-muted/20` instead of `hover:bg-hover-neutral-bg`
3. **Player management modals** use `hover:text-foreground` and `hover:bg-background/50` instead of the semantic tokens
4. **Action-bar suggestion items** use `hover:bg-accent/20` and `hover:bg-accent/40` — accent-specific patterns not in the token system
5. **Icon grid** and **color palette** use their own hover patterns (`hover:bg-card`, `hover:scale-110`)
## Code References
- `apps/web/src/index.css:3-26` — Theme color definitions including hover tokens
- `apps/web/src/components/ui/button.tsx:1-38` — Button component with CVA variants
- `apps/web/src/components/ui/confirm-button.tsx:93-115` — ConfirmButton with destructive hover states
- `apps/web/src/components/ui/overflow-menu.tsx:38-72` — OverflowMenu with non-token hover
- `apps/web/src/components/hp-adjust-popover.tsx:117-129` — Hardcoded red/green hover colors
- `apps/web/src/components/action-bar.tsx:80-188` — Mixed token and accent-based hovers
- `apps/web/src/components/combatant-row.tsx:147-629` — Inline buttons with token hovers
- `apps/web/src/components/player-management.tsx:58-98` — Non-token hover patterns
- `apps/web/src/components/stat-block-panel.tsx:55-109` — Consistent token usage
- `apps/web/src/lib/utils.ts:1-5``cn()` utility (clsx + twMerge)
## Architecture Documentation
The styling architecture follows this pattern:
1. **Theme layer**: `index.css` defines all color tokens via `@theme`, including semantic hover tokens
2. **Component layer**: `Button` (CVA) provides the shared button abstraction with three variants
3. **Composite layer**: `ConfirmButton` and `OverflowMenu` wrap `Button` with additional behavior
4. **Usage layer**: Components use either `Button` component or raw `<button>` elements with inline Tailwind classes
The `cn()` utility from `lib/utils.ts` is used in 9+ components for conditional class merging.
Custom animations are defined in `index.css` via `@keyframes` + `@utility` pairs: slide-in-right, confirm-pulse, settle-to-bottom, rise-to-center, slide-down-in, slide-up-out, concentration-pulse.
## Open Questions
1. The `hover-action` and `hover-action-bg` tokens are defined but rarely used — `hover-action` appears only once in `action-bar.tsx:565`. Is this intentional or an incomplete migration?
2. The `accent` color (`#3b82f6`) is identical to `primary` — are they intended to diverge in the future, or is this redundancy?
3. Should the hardcoded HP adjust colors (red/emerald) be promoted to theme tokens (e.g., `hover-damage`, `hover-heal`)?

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