Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63e233bd8d | ||
|
|
8c62ec28f2 | ||
|
|
72195e90f6 | ||
|
|
6ac8e67970 | ||
|
|
a4797d5b15 | ||
|
|
d48e39ced4 | ||
|
|
b7406c4b54 | ||
|
|
07cdd4867a | ||
|
|
85acb5c185 | ||
|
|
f9ef64bb00 | ||
|
|
bd39808000 | ||
|
|
75778884bd | ||
|
|
72d4f30e60 | ||
|
|
96b37d4bdd | ||
|
|
76ca78c169 | ||
|
|
b0c27b8ab9 | ||
|
|
458c277e9f | ||
|
|
91703ddebc | ||
|
|
768e7a390f | ||
|
|
7feaf90eab | ||
|
|
b39e4923e1 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -2,22 +2,73 @@ import {
|
||||
rollAllInitiativeUseCase,
|
||||
rollInitiativeUseCase,
|
||||
} from "@initiative/application";
|
||||
import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type CombatantId,
|
||||
type Creature,
|
||||
type CreatureId,
|
||||
isDomainError,
|
||||
} from "@initiative/domain";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ActionBar } from "./components/action-bar";
|
||||
import { CombatantRow } from "./components/combatant-row";
|
||||
import { SourceManager } from "./components/source-manager";
|
||||
import { CreatePlayerModal } from "./components/create-player-modal";
|
||||
import { PlayerManagement } from "./components/player-management";
|
||||
import { StatBlockPanel } from "./components/stat-block-panel";
|
||||
import { Toast } from "./components/toast";
|
||||
import { TurnNavigation } from "./components/turn-navigation";
|
||||
import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
|
||||
import { useBulkImport } from "./hooks/use-bulk-import";
|
||||
import { useEncounter } from "./hooks/use-encounter";
|
||||
import { usePlayerCharacters } from "./hooks/use-player-characters";
|
||||
|
||||
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 +85,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,
|
||||
@@ -49,10 +114,12 @@ export function App() {
|
||||
|
||||
const bulkImport = useBulkImport();
|
||||
|
||||
const [rollSkippedCount, setRollSkippedCount] = useState(0);
|
||||
|
||||
const [selectedCreatureId, setSelectedCreatureId] =
|
||||
useState<CreatureId | null>(null);
|
||||
const [bulkImportMode, setBulkImportMode] = useState(false);
|
||||
const [sourceManagerOpen, setSourceManagerOpen] = useState(false);
|
||||
const [sourceManagerMode, setSourceManagerMode] = useState(false);
|
||||
const [isRightPanelFolded, setIsRightPanelFolded] = useState(false);
|
||||
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
|
||||
null,
|
||||
@@ -79,20 +146,14 @@ 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],
|
||||
);
|
||||
|
||||
const handleCombatantStatBlock = useCallback((creatureId: string) => {
|
||||
setSelectedCreatureId(creatureId as CreatureId);
|
||||
setBulkImportMode(false);
|
||||
setSourceManagerMode(false);
|
||||
setIsRightPanelFolded(false);
|
||||
}, []);
|
||||
|
||||
@@ -104,7 +165,10 @@ export function App() {
|
||||
);
|
||||
|
||||
const handleRollAllInitiative = useCallback(() => {
|
||||
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
||||
const result = rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
||||
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
||||
setRollSkippedCount(result.skippedNoSource);
|
||||
}
|
||||
}, [makeStore, getCreature]);
|
||||
|
||||
const handleViewStatBlock = useCallback((result: SearchResult) => {
|
||||
@@ -114,12 +178,23 @@ export function App() {
|
||||
.replace(/(^-|-$)/g, "");
|
||||
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
||||
setSelectedCreatureId(cId);
|
||||
setBulkImportMode(false);
|
||||
setSourceManagerMode(false);
|
||||
setIsRightPanelFolded(false);
|
||||
}, []);
|
||||
|
||||
const handleBulkImport = useCallback(() => {
|
||||
setBulkImportMode(true);
|
||||
setSourceManagerMode(false);
|
||||
setSelectedCreatureId(null);
|
||||
setIsRightPanelFolded(false);
|
||||
}, []);
|
||||
|
||||
const handleOpenSourceManager = useCallback(() => {
|
||||
setSourceManagerMode(true);
|
||||
setBulkImportMode(false);
|
||||
setSelectedCreatureId(null);
|
||||
setIsRightPanelFolded(false);
|
||||
}, []);
|
||||
|
||||
const handleStartBulkImport = useCallback(
|
||||
@@ -142,6 +217,7 @@ export function App() {
|
||||
const handleDismissBrowsePanel = useCallback(() => {
|
||||
setSelectedCreatureId(null);
|
||||
setBulkImportMode(false);
|
||||
setSourceManagerMode(false);
|
||||
}, []);
|
||||
|
||||
const handleToggleFold = useCallback(() => {
|
||||
@@ -160,6 +236,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(() => {
|
||||
@@ -180,38 +259,68 @@ export function App() {
|
||||
const active = encounter.combatants[encounter.activeIndex];
|
||||
if (!active?.creatureId || !isLoaded) return;
|
||||
setSelectedCreatureId(active.creatureId as CreatureId);
|
||||
setBulkImportMode(false);
|
||||
setSourceManagerMode(false);
|
||||
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
|
||||
|
||||
const isEmpty = encounter.combatants.length === 0;
|
||||
const hasCreatureCombatants = encounter.combatants.some(
|
||||
(c) => c.creatureId != null,
|
||||
);
|
||||
const canRollAllInitiative = encounter.combatants.some(
|
||||
(c) => c.creatureId != null && c.initiative == null,
|
||||
);
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
{sourceManagerOpen && (
|
||||
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
|
||||
<SourceManager onCacheCleared={refreshCache} />
|
||||
</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={hasCreatureCombatants}
|
||||
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||
onOpenSourceManager={handleOpenSourceManager}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Scrollable area — combatant list */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="flex flex-col px-2 py-2">
|
||||
{encounter.combatants.length === 0 ? (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||
No combatants yet — add one to get started
|
||||
</p>
|
||||
) : (
|
||||
encounter.combatants.map((c, i) => (
|
||||
{encounter.combatants.map((c, i) => (
|
||||
<CombatantRow
|
||||
key={c.id}
|
||||
ref={i === encounter.activeIndex ? activeRowRef : null}
|
||||
@@ -234,13 +343,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 +360,18 @@ export function App() {
|
||||
onViewStatBlock={handleViewStatBlock}
|
||||
onBulkImport={handleBulkImport}
|
||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
||||
inputRef={actionBarInputRef}
|
||||
playerCharacters={playerCharacters}
|
||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||
onManagePlayers={() => setManagementOpen(true)}
|
||||
onRollAllInitiative={handleRollAllInitiative}
|
||||
showRollAllInitiative={hasCreatureCombatants}
|
||||
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||
onOpenSourceManager={handleOpenSourceManager}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pinned Stat Block Panel (left) */}
|
||||
@@ -293,6 +414,7 @@ export function App() {
|
||||
bulkImportState={bulkImport.state}
|
||||
onStartBulkImport={handleStartBulkImport}
|
||||
onBulkImportDone={handleBulkImportDone}
|
||||
sourceManagerMode={sourceManagerMode}
|
||||
/>
|
||||
|
||||
{/* Toast for bulk import progress when panel is closed */}
|
||||
@@ -321,6 +443,53 @@ export function App() {
|
||||
onDismiss={bulkImport.reset}
|
||||
/>
|
||||
)}
|
||||
|
||||
{rollSkippedCount > 0 && (
|
||||
<Toast
|
||||
message={`${rollSkippedCount} skipped — bestiary source not loaded`}
|
||||
onDismiss={() => setRollSkippedCount(0)}
|
||||
autoDismissMs={4000}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreatePlayerModal
|
||||
open={createPlayerOpen}
|
||||
onClose={() => {
|
||||
setCreatePlayerOpen(false);
|
||||
setEditingPlayer(undefined);
|
||||
}}
|
||||
onSave={(name, ac, maxHp, color, icon) => {
|
||||
if (editingPlayer) {
|
||||
editPlayerCharacter?.(editingPlayer.id, {
|
||||
name,
|
||||
ac,
|
||||
maxHp,
|
||||
color: color ?? null,
|
||||
icon: icon ?? null,
|
||||
});
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,221 @@ 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;
|
||||
rollAllInitiativeDisabled?: 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,
|
||||
onClear,
|
||||
}: {
|
||||
nameInput: string;
|
||||
suggestions: SearchResult[];
|
||||
pcMatches: PlayerCharacter[];
|
||||
suggestionIndex: number;
|
||||
queued: QueuedCreature | null;
|
||||
onDismiss: () => void;
|
||||
onClear: () => 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 = pc.icon
|
||||
? PLAYER_ICON_MAP[pc.icon as PlayerIcon]
|
||||
: undefined;
|
||||
const pcColor = pc.color
|
||||
? PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX]
|
||||
: undefined;
|
||||
return (
|
||||
<li key={pc.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-hover-neutral-bg"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
onAddFromPlayerCharacter?.(pc);
|
||||
onClear();
|
||||
}}
|
||||
>
|
||||
{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: "Import All Sources",
|
||||
onClick: opts.onBulkImport,
|
||||
disabled: opts.bulkImportDisabled,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function ActionBar({
|
||||
onAddCombatant,
|
||||
onAddFromBestiary,
|
||||
@@ -34,22 +257,25 @@ export function ActionBar({
|
||||
onViewStatBlock,
|
||||
onBulkImport,
|
||||
bulkImportDisabled,
|
||||
inputRef,
|
||||
playerCharacters,
|
||||
onAddFromPlayerCharacter,
|
||||
onManagePlayers,
|
||||
onRollAllInitiative,
|
||||
showRollAllInitiative,
|
||||
rollAllInitiativeDisabled,
|
||||
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 +283,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 +314,7 @@ export function ActionBar({
|
||||
|
||||
const handleAdd = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (browseMode) return;
|
||||
if (queued) {
|
||||
confirmQueued();
|
||||
return;
|
||||
@@ -91,20 +330,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 +367,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 +394,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 +409,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 +460,86 @@ 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="+ Add combatants"
|
||||
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}
|
||||
onClear={clearInput}
|
||||
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 +567,24 @@ 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}
|
||||
disabled={rollAllInitiativeDisabled}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -79,11 +75,10 @@ export function BulkImportPrompt({
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Bulk Import Sources
|
||||
Import All Sources
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Load stat block data for all {totalSources} sources at once. This will
|
||||
download approximately 12.5 MB of data.
|
||||
Load stat block data for all {totalSources} sources at once.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -103,11 +98,7 @@ export function BulkImportPrompt({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onStartImport(baseUrl)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Button onClick={() => onStartImport(baseUrl)} disabled={isDisabled}>
|
||||
Load All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
36
apps/web/src/components/color-palette.tsx
Normal file
36
apps/web/src/components/color-palette.tsx
Normal 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(value === color ? "" : 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
187
apps/web/src/components/create-player-modal.tsx
Normal file
187
apps/web/src/components/create-player-modal.tsx
Normal 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 | undefined,
|
||||
icon: string | undefined,
|
||||
) => 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("");
|
||||
setIcon("");
|
||||
}
|
||||
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 || undefined, icon || undefined);
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
38
apps/web/src/components/icon-grid.tsx
Normal file
38
apps/web/src/components/icon-grid.tsx
Normal 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(value === iconId ? "" : 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>
|
||||
);
|
||||
}
|
||||
50
apps/web/src/components/player-icon-map.ts
Normal file
50
apps/web/src/components/player-icon-map.ts
Normal 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",
|
||||
};
|
||||
121
apps/web/src/components/player-management.tsx
Normal file
121
apps/web/src/components/player-management.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { PlayerCharacter, PlayerCharacterId } 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 = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
|
||||
const color = pc.color ? PLAYER_COLOR_HEX[pc.color] : undefined;
|
||||
return (
|
||||
<div
|
||||
key={pc.id}
|
||||
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-hover-neutral-bg"
|
||||
>
|
||||
{Icon && (
|
||||
<Icon size={18} style={{ color }} className="shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate text-sm text-foreground">
|
||||
{pc.name}
|
||||
</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
AC {pc.ac}
|
||||
</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
HP {pc.maxHp}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onEdit(pc)}
|
||||
className="text-muted-foreground"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<ConfirmButton
|
||||
icon={<Trash2 size={14} />}
|
||||
label="Delete player character"
|
||||
onConfirm={() => onDelete(pc.id)}
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button onClick={onCreate} variant="ghost">
|
||||
<Plus size={16} />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
||||
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
||||
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
||||
import { SourceManager } from "./source-manager.js";
|
||||
import { StatBlock } from "./stat-block.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
|
||||
interface StatBlockPanelProps {
|
||||
creatureId: CreatureId | null;
|
||||
@@ -31,6 +33,7 @@ interface StatBlockPanelProps {
|
||||
bulkImportState?: BulkImportState;
|
||||
onStartBulkImport?: (baseUrl: string) => void;
|
||||
onBulkImportDone?: () => void;
|
||||
sourceManagerMode?: boolean;
|
||||
}
|
||||
|
||||
function extractSourceCode(cId: CreatureId): string {
|
||||
@@ -81,36 +84,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 +201,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}
|
||||
@@ -231,6 +238,7 @@ export function StatBlockPanel({
|
||||
bulkImportState,
|
||||
onStartBulkImport,
|
||||
onBulkImportDone,
|
||||
sourceManagerMode,
|
||||
}: StatBlockPanelProps) {
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
() => window.matchMedia("(min-width: 1024px)").matches,
|
||||
@@ -264,7 +272,7 @@ export function StatBlockPanel({
|
||||
});
|
||||
}, [creatureId, creature, isSourceCached]);
|
||||
|
||||
if (!creatureId && !bulkImportMode) return null;
|
||||
if (!creatureId && !bulkImportMode && !sourceManagerMode) return null;
|
||||
|
||||
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
||||
|
||||
@@ -274,6 +282,10 @@ export function StatBlockPanel({
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (sourceManagerMode) {
|
||||
return <SourceManager onCacheCleared={refreshCache} />;
|
||||
}
|
||||
|
||||
if (
|
||||
bulkImportMode &&
|
||||
bulkImportState &&
|
||||
@@ -319,7 +331,12 @@ export function StatBlockPanel({
|
||||
};
|
||||
|
||||
const creatureName =
|
||||
creature?.name ?? (bulkImportMode ? "Bulk Import" : "Creature");
|
||||
creature?.name ??
|
||||
(sourceManagerMode
|
||||
? "Sources"
|
||||
: bulkImportMode
|
||||
? "Import All Sources"
|
||||
: "Creature");
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
|
||||
@@ -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;
|
||||
@@ -22,7 +23,7 @@ export function Toast({
|
||||
}, [autoDismissMs, onDismiss]);
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2">
|
||||
<div className="fixed bottom-4 left-4 z-50">
|
||||
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg">
|
||||
<span className="text-sm text-foreground">{message}</span>
|
||||
{progress !== undefined && (
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
72
apps/web/src/components/ui/overflow-menu.tsx
Normal file
72
apps/web/src/components/ui/overflow-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
108
apps/web/src/hooks/use-player-characters.ts
Normal file
108
apps/web/src/hooks/use-player-characters.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
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 | null;
|
||||
readonly icon?: string | null;
|
||||
}
|
||||
|
||||
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 | undefined,
|
||||
icon: string | undefined,
|
||||
) => {
|
||||
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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
77
apps/web/src/persistence/player-character-storage.ts
Normal file
77
apps/web/src/persistence/player-character-storage.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
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 isValidOptionalMember(
|
||||
value: unknown,
|
||||
valid: ReadonlySet<string>,
|
||||
): boolean {
|
||||
return value === undefined || (typeof value === "string" && valid.has(value));
|
||||
}
|
||||
|
||||
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 (!isValidOptionalMember(entry.color, VALID_PLAYER_COLORS)) return null;
|
||||
if (!isValidOptionalMember(entry.icon, VALID_PLAYER_ICONS)) 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 [];
|
||||
}
|
||||
}
|
||||
536
docs/agents/plans/2026-03-13-declutter-action-bars.md
Normal file
536
docs/agents/plans/2026-03-13-declutter-action-bars.md
Normal 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`
|
||||
176
docs/agents/research/2026-03-13-action-bars-and-buttons.md
Normal file
176
docs/agents/research/2026-03-13-action-bars-and-buttons.md
Normal 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**: Left–Center–Right 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.
|
||||
188
docs/agents/research/2026-03-13-css-classes-buttons-hover.md
Normal file
188
docs/agents/research/2026-03-13-css-classes-buttons-hover.md
Normal 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`)?
|
||||
36
packages/application/src/create-player-character-use-case.ts
Normal file
36
packages/application/src/create-player-character-use-case.ts
Normal 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 | undefined,
|
||||
icon: string | undefined,
|
||||
): 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;
|
||||
}
|
||||
23
packages/application/src/delete-player-character-use-case.ts
Normal file
23
packages/application/src/delete-player-character-use-case.ts
Normal 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;
|
||||
}
|
||||
32
packages/application/src/edit-player-character-use-case.ts
Normal file
32
packages/application/src/edit-player-character-use-case.ts
Normal 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 | null;
|
||||
readonly icon?: string | null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -2,11 +2,21 @@ 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";
|
||||
export {
|
||||
type RollAllResult,
|
||||
rollAllInitiativeUseCase,
|
||||
} from "./roll-all-initiative-use-case.js";
|
||||
export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
|
||||
export { setAcUseCase } from "./set-ac-use-case.js";
|
||||
export { setHpUseCase } from "./set-hp-use-case.js";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -10,20 +10,29 @@ import {
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
|
||||
export interface RollAllResult {
|
||||
events: DomainEvent[];
|
||||
skippedNoSource: number;
|
||||
}
|
||||
|
||||
export function rollAllInitiativeUseCase(
|
||||
store: EncounterStore,
|
||||
rollDice: () => number,
|
||||
getCreature: (id: CreatureId) => Creature | undefined,
|
||||
): DomainEvent[] | DomainError {
|
||||
): RollAllResult | DomainError {
|
||||
let encounter = store.get();
|
||||
const allEvents: DomainEvent[] = [];
|
||||
let skippedNoSource = 0;
|
||||
|
||||
for (const combatant of encounter.combatants) {
|
||||
if (!combatant.creatureId) continue;
|
||||
if (combatant.initiative !== undefined) continue;
|
||||
|
||||
const creature = getCreature(combatant.creatureId);
|
||||
if (!creature) continue;
|
||||
if (!creature) {
|
||||
skippedNoSource++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { modifier } = calculateInitiative({
|
||||
dexScore: creature.abilities.dex,
|
||||
@@ -47,5 +56,5 @@ export function rollAllInitiativeUseCase(
|
||||
}
|
||||
|
||||
store.save(encounter);
|
||||
return allEvents;
|
||||
return { events: allEvents, skippedNoSource };
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
270
packages/domain/src/__tests__/create-player-character.test.ts
Normal file
270
packages/domain/src/__tests__/create-player-character.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
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("allows undefined color", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
10,
|
||||
50,
|
||||
undefined,
|
||||
"sword",
|
||||
);
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
expect(result.characters[0].color).toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows undefined icon", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
10,
|
||||
50,
|
||||
"blue",
|
||||
undefined,
|
||||
);
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
expect(result.characters[0].icon).toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows both color and icon undefined", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
10,
|
||||
50,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
expect(result.characters[0].color).toBeUndefined();
|
||||
expect(result.characters[0].icon).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits exactly one event on success", () => {
|
||||
const { events } = success([], "Test", 10, 50);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].type).toBe("PlayerCharacterCreated");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
133
packages/domain/src/__tests__/edit-player-character.test.ts
Normal file
133
packages/domain/src/__tests__/edit-player-character.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
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("clears color when set to null", () => {
|
||||
const result = editPlayerCharacter([makePC({ color: "green" })], id, {
|
||||
color: null,
|
||||
});
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
expect(result.characters[0].color).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears icon when set to null", () => {
|
||||
const result = editPlayerCharacter([makePC({ icon: "sword" })], id, {
|
||||
icon: null,
|
||||
});
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
expect(result.characters[0].icon).toBeUndefined();
|
||||
});
|
||||
|
||||
it("event includes old and new name", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
87
packages/domain/src/create-player-character.ts
Normal file
87
packages/domain/src/create-player-character.ts
Normal 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 | undefined,
|
||||
icon: string | undefined,
|
||||
): 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 (color !== undefined && !VALID_PLAYER_COLORS.has(color)) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-color",
|
||||
message: `Invalid color: ${color}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (icon !== undefined && !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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
39
packages/domain/src/delete-player-character.ts
Normal file
39
packages/domain/src/delete-player-character.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
145
packages/domain/src/edit-player-character.ts
Normal file
145
packages/domain/src/edit-player-character.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
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 | null;
|
||||
readonly icon?: string | null;
|
||||
}
|
||||
|
||||
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 &&
|
||||
fields.color !== null &&
|
||||
!VALID_PLAYER_COLORS.has(fields.color)
|
||||
) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-color",
|
||||
message: `Invalid color: ${fields.color}`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
fields.icon !== undefined &&
|
||||
fields.icon !== null &&
|
||||
!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"]) ?? undefined)
|
||||
: existing.color,
|
||||
icon:
|
||||
fields.icon !== undefined
|
||||
? ((fields.icon as PlayerCharacter["icon"]) ?? undefined)
|
||||
: 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
81
packages/domain/src/player-character-types.ts
Normal file
81
packages/domain/src/player-character-types.ts
Normal 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[];
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
34
specs/005-player-characters/checklists/requirements.md
Normal file
34
specs/005-player-characters/checklists/requirements.md
Normal 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`.
|
||||
93
specs/005-player-characters/contracts/ui-contracts.md
Normal file
93
specs/005-player-characters/contracts/ui-contracts.md
Normal 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"
|
||||
141
specs/005-player-characters/data-model.md
Normal file
141
specs/005-player-characters/data-model.md
Normal 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.
|
||||
100
specs/005-player-characters/plan.md
Normal file
100
specs/005-player-characters/plan.md
Normal 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.
|
||||
62
specs/005-player-characters/quickstart.md
Normal file
62
specs/005-player-characters/quickstart.md
Normal 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
|
||||
66
specs/005-player-characters/research.md
Normal file
66
specs/005-player-characters/research.md
Normal 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.
|
||||
262
specs/005-player-characters/spec.md
Normal file
262
specs/005-player-characters/spec.md
Normal 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.
|
||||
252
specs/005-player-characters/tasks.md
Normal file
252
specs/005-player-characters/tasks.md
Normal 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
|
||||
Reference in New Issue
Block a user