Overhaul bottom bar: batch add, custom fields, stat block viewer
Unify the action bar into a single search input with inline bestiary dropdown. Clicking a dropdown entry queues it with +/- count controls and a confirm button; Enter or confirm adds N copies to combat. When no bestiary match exists, optional Init/AC/MaxHP fields appear for custom creatures. The eye icon opens a separate search dropdown to preview stat blocks without leaving the add flow. Fix batch-add bug where only the last creature got a creatureId by using store.save() instead of setEncounter() in addFromBestiary. Prevent dropdown buttons from stealing input focus so Enter confirms the queued batch. Remove the now-redundant BestiarySearch component. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -88,6 +88,8 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
|
|||||||
- N/A (no persistence changes — display-only refactor) (034-topbar-redesign)
|
- N/A (no persistence changes — display-only refactor) (034-topbar-redesign)
|
||||||
- TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Tailwind CSS v4, Lucide React (icons), class-variance-authority (035-statblock-fold-pin)
|
- TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Tailwind CSS v4, Lucide React (icons), class-variance-authority (035-statblock-fold-pin)
|
||||||
- N/A (no persistence changes — all new state is ephemeral) (035-statblock-fold-pin)
|
- N/A (no persistence changes — all new state is ephemeral) (035-statblock-fold-pin)
|
||||||
|
- TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Tailwind CSS v4, Lucide React (icons) (036-bottombar-overhaul)
|
||||||
|
- N/A (no persistence changes — queue state and custom fields are ephemeral) (036-bottombar-overhaul)
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 032-inline-confirm-buttons: Added TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Tailwind CSS v4, Lucide React, class-variance-authority (cva)
|
- 032-inline-confirm-buttons: Added TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Tailwind CSS v4, Lucide React, class-variance-authority (cva)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e
|
|||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
- **Initiative tracking** — add combatants, roll initiative (manual or d20), cycle turns and rounds
|
- **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
|
- **Encounter state** — HP, AC, conditions, concentration tracking with visual status indicators
|
||||||
- **Bestiary integration** — import bestiary JSON sources, search creatures, and view full stat blocks
|
- **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
|
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB
|
||||||
|
|||||||
@@ -107,6 +107,16 @@ export function App() {
|
|||||||
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
||||||
}, [makeStore, getCreature]);
|
}, [makeStore, getCreature]);
|
||||||
|
|
||||||
|
const handleViewStatBlock = useCallback((result: SearchResult) => {
|
||||||
|
const slug = result.name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/(^-|-$)/g, "");
|
||||||
|
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
||||||
|
setSelectedCreatureId(cId);
|
||||||
|
setIsRightPanelFolded(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleBulkImport = useCallback(() => {
|
const handleBulkImport = useCallback(() => {
|
||||||
setBulkImportMode(true);
|
setBulkImportMode(true);
|
||||||
setSelectedCreatureId(null);
|
setSelectedCreatureId(null);
|
||||||
@@ -234,6 +244,7 @@ export function App() {
|
|||||||
onAddFromBestiary={handleAddFromBestiary}
|
onAddFromBestiary={handleAddFromBestiary}
|
||||||
bestiarySearch={search}
|
bestiarySearch={search}
|
||||||
bestiaryLoaded={isLoaded}
|
bestiaryLoaded={isLoaded}
|
||||||
|
onViewStatBlock={handleViewStatBlock}
|
||||||
onBulkImport={handleBulkImport}
|
onBulkImport={handleBulkImport}
|
||||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
bulkImportDisabled={bulkImport.state.status === "loading"}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,61 +1,136 @@
|
|||||||
import { Import, Search } from "lucide-react";
|
import { Check, Eye, Import, Minus, Plus } from "lucide-react";
|
||||||
import { type FormEvent, useState } from "react";
|
import { type FormEvent, useEffect, useRef, useState } from "react";
|
||||||
import type { SearchResult } from "../hooks/use-bestiary.js";
|
import type { SearchResult } from "../hooks/use-bestiary.js";
|
||||||
import { BestiarySearch } from "./bestiary-search.js";
|
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
|
interface QueuedCreature {
|
||||||
|
result: SearchResult;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ActionBarProps {
|
interface ActionBarProps {
|
||||||
onAddCombatant: (name: string) => void;
|
onAddCombatant: (
|
||||||
|
name: string,
|
||||||
|
opts?: { initiative?: number; ac?: number; maxHp?: number },
|
||||||
|
) => void;
|
||||||
onAddFromBestiary: (result: SearchResult) => void;
|
onAddFromBestiary: (result: SearchResult) => void;
|
||||||
bestiarySearch: (query: string) => SearchResult[];
|
bestiarySearch: (query: string) => SearchResult[];
|
||||||
bestiaryLoaded: boolean;
|
bestiaryLoaded: boolean;
|
||||||
|
onViewStatBlock?: (result: SearchResult) => void;
|
||||||
onBulkImport?: () => void;
|
onBulkImport?: () => void;
|
||||||
bulkImportDisabled?: boolean;
|
bulkImportDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function creatureKey(r: SearchResult): string {
|
||||||
|
return `${r.source}:${r.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function ActionBar({
|
export function ActionBar({
|
||||||
onAddCombatant,
|
onAddCombatant,
|
||||||
onAddFromBestiary,
|
onAddFromBestiary,
|
||||||
bestiarySearch,
|
bestiarySearch,
|
||||||
bestiaryLoaded,
|
bestiaryLoaded,
|
||||||
|
onViewStatBlock,
|
||||||
onBulkImport,
|
onBulkImport,
|
||||||
bulkImportDisabled,
|
bulkImportDisabled,
|
||||||
}: ActionBarProps) {
|
}: ActionBarProps) {
|
||||||
const [nameInput, setNameInput] = useState("");
|
const [nameInput, setNameInput] = useState("");
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
|
||||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||||
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
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 clearCustomFields = () => {
|
||||||
|
setCustomInit("");
|
||||||
|
setCustomAc("");
|
||||||
|
setCustomMaxHp("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmQueued = () => {
|
||||||
|
if (!queued) return;
|
||||||
|
for (let i = 0; i < queued.count; i++) {
|
||||||
|
onAddFromBestiary(queued.result);
|
||||||
|
}
|
||||||
|
setQueued(null);
|
||||||
|
setNameInput("");
|
||||||
|
setSuggestions([]);
|
||||||
|
setSuggestionIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseNum = (v: string): number | undefined => {
|
||||||
|
if (v.trim() === "") return undefined;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isNaN(n) ? undefined : n;
|
||||||
|
};
|
||||||
|
|
||||||
const handleAdd = (e: FormEvent) => {
|
const handleAdd = (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (queued) {
|
||||||
|
confirmQueued();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (nameInput.trim() === "") return;
|
if (nameInput.trim() === "") return;
|
||||||
onAddCombatant(nameInput);
|
const opts: { initiative?: number; ac?: number; maxHp?: number } = {};
|
||||||
|
const init = parseNum(customInit);
|
||||||
|
const ac = parseNum(customAc);
|
||||||
|
const maxHp = parseNum(customMaxHp);
|
||||||
|
if (init !== undefined) opts.initiative = init;
|
||||||
|
if (ac !== undefined) opts.ac = ac;
|
||||||
|
if (maxHp !== undefined) opts.maxHp = maxHp;
|
||||||
|
onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
|
||||||
setNameInput("");
|
setNameInput("");
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
|
clearCustomFields();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNameChange = (value: string) => {
|
const handleNameChange = (value: string) => {
|
||||||
setNameInput(value);
|
setNameInput(value);
|
||||||
setSuggestionIndex(-1);
|
setSuggestionIndex(-1);
|
||||||
|
let newSuggestions: SearchResult[] = [];
|
||||||
if (value.length >= 2) {
|
if (value.length >= 2) {
|
||||||
setSuggestions(bestiarySearch(value));
|
newSuggestions = bestiarySearch(value);
|
||||||
|
setSuggestions(newSuggestions);
|
||||||
} else {
|
} else {
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
}
|
}
|
||||||
|
if (newSuggestions.length > 0) {
|
||||||
|
clearCustomFields();
|
||||||
|
}
|
||||||
|
if (queued) {
|
||||||
|
const qKey = creatureKey(queued.result);
|
||||||
|
const stillVisible = newSuggestions.some((s) => creatureKey(s) === qKey);
|
||||||
|
if (!stillVisible) {
|
||||||
|
setQueued(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectCreature = (result: SearchResult) => {
|
const handleClickSuggestion = (result: SearchResult) => {
|
||||||
onAddFromBestiary(result);
|
const key = creatureKey(result);
|
||||||
setSearchOpen(false);
|
if (queued && creatureKey(queued.result) === key) {
|
||||||
setNameInput("");
|
setQueued({ ...queued, count: queued.count + 1 });
|
||||||
setSuggestions([]);
|
} else {
|
||||||
|
setQueued({ result, count: 1 });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectSuggestion = (result: SearchResult) => {
|
const handleEnter = () => {
|
||||||
onAddFromBestiary(result);
|
if (queued) {
|
||||||
setNameInput("");
|
confirmQueued();
|
||||||
setSuggestions([]);
|
} else if (suggestionIndex >= 0) {
|
||||||
|
handleClickSuggestion(suggestions[suggestionIndex]);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
@@ -67,91 +142,276 @@ export function ActionBar({
|
|||||||
} else if (e.key === "ArrowUp") {
|
} else if (e.key === "ArrowUp") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
||||||
} else if (e.key === "Enter" && suggestionIndex >= 0) {
|
} else if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSelectSuggestion(suggestions[suggestionIndex]);
|
handleEnter();
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
|
setQueued(null);
|
||||||
setSuggestionIndex(-1);
|
setSuggestionIndex(-1);
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
closeViewer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (viewerResults.length === 0) return;
|
||||||
|
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setViewerIndex((i) => (i < viewerResults.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) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleViewerSelect(viewerResults[viewerIndex]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
||||||
{searchOpen ? (
|
<form
|
||||||
<BestiarySearch
|
onSubmit={handleAdd}
|
||||||
onSelectCreature={handleSelectCreature}
|
className="relative flex flex-1 items-center gap-2"
|
||||||
onClose={() => setSearchOpen(false)}
|
>
|
||||||
searchFn={bestiarySearch}
|
<div className="relative flex-1">
|
||||||
/>
|
<Input
|
||||||
) : (
|
type="text"
|
||||||
<form
|
value={nameInput}
|
||||||
onSubmit={handleAdd}
|
onChange={(e) => handleNameChange(e.target.value)}
|
||||||
className="relative flex flex-1 items-center gap-2"
|
onKeyDown={handleKeyDown}
|
||||||
>
|
placeholder="Search creatures to add..."
|
||||||
<div className="relative flex-1">
|
className="max-w-xs"
|
||||||
<Input
|
/>
|
||||||
type="text"
|
{suggestions.length > 0 && (
|
||||||
value={nameInput}
|
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
|
||||||
onChange={(e) => handleNameChange(e.target.value)}
|
<ul className="max-h-48 overflow-y-auto py-1">
|
||||||
onKeyDown={handleKeyDown}
|
{suggestions.map((result, i) => {
|
||||||
placeholder="Combatant name"
|
const key = creatureKey(result);
|
||||||
className="max-w-xs"
|
const isQueued =
|
||||||
/>
|
queued !== null && creatureKey(queued.result) === key;
|
||||||
{suggestions.length > 0 && (
|
return (
|
||||||
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
|
<li key={key}>
|
||||||
<ul className="max-h-48 overflow-y-auto py-1">
|
|
||||||
{suggestions.map((result, i) => (
|
|
||||||
<li key={`${result.source}:${result.name}`}>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
||||||
i === suggestionIndex
|
isQueued
|
||||||
? "bg-accent/20 text-foreground"
|
? "bg-accent/30 text-foreground"
|
||||||
: "text-foreground hover:bg-hover-neutral-bg"
|
: i === suggestionIndex
|
||||||
|
? "bg-accent/20 text-foreground"
|
||||||
|
: "text-foreground hover:bg-hover-neutral-bg"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleSelectSuggestion(result)}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => handleClickSuggestion(result)}
|
||||||
onMouseEnter={() => setSuggestionIndex(i)}
|
onMouseEnter={() => setSuggestionIndex(i)}
|
||||||
>
|
>
|
||||||
<span>{result.name}</span>
|
<span>{result.name}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
{result.sourceDisplayName}
|
{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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
</ul>
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{nameInput.length >= 2 && suggestions.length === 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={customInit}
|
||||||
|
onChange={(e) => setCustomInit(e.target.value)}
|
||||||
|
placeholder="Init"
|
||||||
|
className="w-16 text-center"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={customAc}
|
||||||
|
onChange={(e) => setCustomAc(e.target.value)}
|
||||||
|
placeholder="AC"
|
||||||
|
className="w-16 text-center"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={customMaxHp}
|
||||||
|
onChange={(e) => setCustomMaxHp(e.target.value)}
|
||||||
|
placeholder="MaxHP"
|
||||||
|
className="w-18 text-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button type="submit" size="sm">
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
{bestiaryLoaded && onViewStatBlock && (
|
||||||
|
<div ref={viewerRef} className="relative">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => (viewerOpen ? closeViewer() : openViewer())}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" size="sm">
|
)}
|
||||||
Add
|
{bestiaryLoaded && onBulkImport && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onBulkImport}
|
||||||
|
disabled={bulkImportDisabled}
|
||||||
|
>
|
||||||
|
<Import className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{bestiaryLoaded && (
|
)}
|
||||||
<>
|
</form>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setSearchOpen(true)}
|
|
||||||
>
|
|
||||||
<Search className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
{onBulkImport && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={onBulkImport}
|
|
||||||
disabled={bulkImportDisabled}
|
|
||||||
>
|
|
||||||
<Import className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
import { Search, X } from "lucide-react";
|
|
||||||
import {
|
|
||||||
type KeyboardEvent,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import type { SearchResult } from "../hooks/use-bestiary.js";
|
|
||||||
import { Input } from "./ui/input.js";
|
|
||||||
|
|
||||||
interface BestiarySearchProps {
|
|
||||||
onSelectCreature: (result: SearchResult) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
searchFn: (query: string) => SearchResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BestiarySearch({
|
|
||||||
onSelectCreature,
|
|
||||||
onClose,
|
|
||||||
searchFn,
|
|
||||||
}: BestiarySearchProps) {
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [highlightIndex, setHighlightIndex] = useState(-1);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const results = query.length >= 2 ? searchFn(query) : [];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setHighlightIndex(-1);
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClickOutside(e: MouseEvent) {
|
|
||||||
if (
|
|
||||||
containerRef.current &&
|
|
||||||
!containerRef.current.contains(e.target as Node)
|
|
||||||
) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
(e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
onClose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
setHighlightIndex((i) => (i < results.length - 1 ? i + 1 : 0));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
setHighlightIndex((i) => (i > 0 ? i - 1 : results.length - 1));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "Enter" && highlightIndex >= 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
onSelectCreature(results[highlightIndex]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[results, highlightIndex, onClose, onSelectCreature],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className="relative w-full max-w-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Search className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Search bestiary..."
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-muted-foreground hover:text-hover-neutral"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{query.length >= 2 && (
|
|
||||||
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
|
|
||||||
{results.length === 0 ? (
|
|
||||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
|
||||||
No creatures found
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ul className="max-h-60 overflow-y-auto py-1">
|
|
||||||
{results.map((result, i) => (
|
|
||||||
<li key={`${result.source}:${result.name}`}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
|
||||||
i === highlightIndex
|
|
||||||
? "bg-accent/20 text-foreground"
|
|
||||||
: "text-foreground hover:bg-hover-neutral-bg"
|
|
||||||
}`}
|
|
||||||
onClick={() => onSelectCreature(result)}
|
|
||||||
onMouseEnter={() => setHighlightIndex(i)}
|
|
||||||
>
|
|
||||||
<span>{result.name}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{result.sourceDisplayName}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -65,6 +65,33 @@ function deriveNextId(encounter: Encounter): number {
|
|||||||
return max;
|
return max;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CombatantOpts {
|
||||||
|
initiative?: number;
|
||||||
|
ac?: number;
|
||||||
|
maxHp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCombatantOpts(
|
||||||
|
makeStore: () => EncounterStore,
|
||||||
|
id: ReturnType<typeof combatantId>,
|
||||||
|
opts: CombatantOpts,
|
||||||
|
): DomainEvent[] {
|
||||||
|
const events: DomainEvent[] = [];
|
||||||
|
if (opts.maxHp !== undefined) {
|
||||||
|
const r = setHpUseCase(makeStore(), id, opts.maxHp);
|
||||||
|
if (!isDomainError(r)) events.push(...r);
|
||||||
|
}
|
||||||
|
if (opts.ac !== undefined) {
|
||||||
|
const r = setAcUseCase(makeStore(), id, opts.ac);
|
||||||
|
if (!isDomainError(r)) events.push(...r);
|
||||||
|
}
|
||||||
|
if (opts.initiative !== undefined) {
|
||||||
|
const r = setInitiativeUseCase(makeStore(), id, opts.initiative);
|
||||||
|
if (!isDomainError(r)) events.push(...r);
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
export function useEncounter() {
|
export function useEncounter() {
|
||||||
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
|
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
|
||||||
const [events, setEvents] = useState<DomainEvent[]>([]);
|
const [events, setEvents] = useState<DomainEvent[]>([]);
|
||||||
@@ -108,7 +135,7 @@ export function useEncounter() {
|
|||||||
const nextId = useRef(deriveNextId(encounter));
|
const nextId = useRef(deriveNextId(encounter));
|
||||||
|
|
||||||
const addCombatant = useCallback(
|
const addCombatant = useCallback(
|
||||||
(name: string) => {
|
(name: string, opts?: CombatantOpts) => {
|
||||||
const id = combatantId(`c-${++nextId.current}`);
|
const id = combatantId(`c-${++nextId.current}`);
|
||||||
const result = addCombatantUseCase(makeStore(), id, name);
|
const result = addCombatantUseCase(makeStore(), id, name);
|
||||||
|
|
||||||
@@ -116,6 +143,13 @@ export function useEncounter() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opts) {
|
||||||
|
const optEvents = applyCombatantOpts(makeStore, id, opts);
|
||||||
|
if (optEvents.length > 0) {
|
||||||
|
setEvents((prev) => [...prev, ...optEvents]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore],
|
||||||
@@ -279,15 +313,14 @@ export function useEncounter() {
|
|||||||
.replace(/(^-|-$)/g, "");
|
.replace(/(^-|-$)/g, "");
|
||||||
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
|
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
|
||||||
|
|
||||||
// Set creatureId on the combatant
|
// Set creatureId on the combatant (use store.save to keep ref in sync for batch calls)
|
||||||
const currentEncounter = store.get();
|
const currentEncounter = store.get();
|
||||||
const updated = {
|
store.save({
|
||||||
...currentEncounter,
|
...currentEncounter,
|
||||||
combatants: currentEncounter.combatants.map((c) =>
|
combatants: currentEncounter.combatants.map((c) =>
|
||||||
c.id === id ? { ...c, creatureId: cId } : c,
|
c.id === id ? { ...c, creatureId: cId } : c,
|
||||||
),
|
),
|
||||||
};
|
});
|
||||||
setEncounter(updated);
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...addResult]);
|
setEvents((prev) => [...prev, ...addResult]);
|
||||||
},
|
},
|
||||||
|
|||||||
34
specs/036-bottombar-overhaul/checklists/requirements.md
Normal file
34
specs/036-bottombar-overhaul/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Specification Quality Checklist: Bottom Bar Overhaul
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-11
|
||||||
|
**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`.
|
||||||
78
specs/036-bottombar-overhaul/data-model.md
Normal file
78
specs/036-bottombar-overhaul/data-model.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Data Model: Bottom Bar Overhaul
|
||||||
|
|
||||||
|
## Existing Domain Entities (no changes)
|
||||||
|
|
||||||
|
### Combatant
|
||||||
|
|
||||||
|
Already supports all fields needed for custom creature input:
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| id | CombatantId (branded string) | Auto-generated (c-1, c-2, ...) |
|
||||||
|
| name | string | Required, non-empty |
|
||||||
|
| initiative | number? | Optional — new: settable at add time for custom creatures |
|
||||||
|
| maxHp | number? | Optional — new: settable at add time for custom creatures |
|
||||||
|
| currentHp | number? | Optional — set equal to maxHp when provided |
|
||||||
|
| ac | number? | Optional — new: settable at add time for custom creatures |
|
||||||
|
| conditions | readonly ConditionId[]? | Not relevant to this feature |
|
||||||
|
| isConcentrating | boolean? | Not relevant to this feature |
|
||||||
|
| creatureId | CreatureId? | Set for bestiary creatures, absent for custom |
|
||||||
|
|
||||||
|
### SearchResult (BestiaryIndexEntry)
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| name | string | Creature name |
|
||||||
|
| source | string | Source code (e.g., "srd") |
|
||||||
|
| sourceDisplayName | string | Display name of sourcebook |
|
||||||
|
| ac | number | Armor class |
|
||||||
|
| hp | number | Average HP |
|
||||||
|
| dex | number | Dexterity score |
|
||||||
|
| cr | string | Challenge rating |
|
||||||
|
| size | string | Size category |
|
||||||
|
| type | string | Creature type |
|
||||||
|
|
||||||
|
## New UI-Only State (ephemeral, not persisted)
|
||||||
|
|
||||||
|
### QueuedCreature
|
||||||
|
|
||||||
|
Transient state held in the action bar component representing a bestiary creature selected for batch-add.
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| result | SearchResult | The bestiary entry to add |
|
||||||
|
| count | number | Number of copies to add (starts at 1, increments on re-click) |
|
||||||
|
|
||||||
|
**Lifecycle**: Created on first click of a dropdown entry. Incremented on re-click of same entry. Replaced on click of different entry. Cleared on confirm, Escape, or when the queued creature leaves search results.
|
||||||
|
|
||||||
|
### CustomCreatureFields
|
||||||
|
|
||||||
|
Transient state held in the action bar component for optional fields shown when no bestiary match exists.
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| initiative | string | Raw input (parsed to number on submit, empty = omit) |
|
||||||
|
| ac | string | Raw input (parsed to number on submit, empty = omit) |
|
||||||
|
| maxHp | string | Raw input (parsed to number on submit, empty = omit) |
|
||||||
|
|
||||||
|
**Lifecycle**: Visible when search query yields no bestiary results. Cleared on submit or when bestiary results appear.
|
||||||
|
|
||||||
|
## State Transitions
|
||||||
|
|
||||||
|
```
|
||||||
|
[Empty input]
|
||||||
|
→ type 2+ chars with matches → [Dropdown with bestiary results]
|
||||||
|
→ type 2+ chars without matches → [Custom creature fields visible]
|
||||||
|
|
||||||
|
[Dropdown with bestiary results]
|
||||||
|
→ click entry → [Queue: entry x1]
|
||||||
|
→ click same entry → [Queue: entry x(N+1)]
|
||||||
|
→ click different entry → [Queue: new entry x1]
|
||||||
|
→ click confirm / Enter → add N copies → [Empty input]
|
||||||
|
→ Escape → [Empty input]
|
||||||
|
→ change query (queued creature gone from results) → [Queue cleared]
|
||||||
|
|
||||||
|
[Custom creature fields visible]
|
||||||
|
→ submit → add custom creature with optional fields → [Empty input]
|
||||||
|
→ type changes to match bestiary → [Dropdown with bestiary results]
|
||||||
|
```
|
||||||
68
specs/036-bottombar-overhaul/plan.md
Normal file
68
specs/036-bottombar-overhaul/plan.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Implementation Plan: Bottom Bar Overhaul
|
||||||
|
|
||||||
|
**Branch**: `036-bottombar-overhaul` | **Date**: 2026-03-11 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/036-bottombar-overhaul/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Overhaul the bottom bar action bar to support batch-adding predefined creatures (click-to-queue with count + confirm), optional stat fields for custom creatures (initiative, AC, max HP), stat block preview from the search dropdown, and improved placeholder text. All changes are in the adapter (web) layer — no domain or application changes needed. The existing `addFromBestiary` hook method is called N times in a loop for batch adds.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: TypeScript 5.8 (strict mode, `verbatimModuleSyntax`)
|
||||||
|
**Primary Dependencies**: React 19, Tailwind CSS v4, Lucide React (icons)
|
||||||
|
**Storage**: N/A (no persistence changes — queue state and custom fields are ephemeral)
|
||||||
|
**Testing**: Vitest (unit tests for any extracted logic)
|
||||||
|
**Target Platform**: Web (desktop + mobile responsive)
|
||||||
|
**Project Type**: Web application (monorepo: apps/web, packages/domain, packages/application)
|
||||||
|
**Performance Goals**: Instant UI response for queue interactions
|
||||||
|
**Constraints**: Local-first, single-user, offline-capable
|
||||||
|
**Scale/Scope**: Single encounter screen, bottom bar component
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Deterministic Domain Core | PASS | No domain changes. Queue state is UI-only. |
|
||||||
|
| II. Layered Architecture | PASS | All changes in adapter layer (apps/web). Calls existing domain/application functions. |
|
||||||
|
| III. Clarification-First | PASS | No non-trivial assumptions — spec is detailed. |
|
||||||
|
| IV. Escalation Gates | PASS | Implementation stays within spec scope. |
|
||||||
|
| V. MVP Baseline Language | PASS | No permanent bans introduced. |
|
||||||
|
| VI. No Gameplay Rules | PASS | No gameplay mechanics in plan. |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/036-bottombar-overhaul/
|
||||||
|
├── plan.md # This file
|
||||||
|
├── research.md # Phase 0 output
|
||||||
|
├── data-model.md # Phase 1 output
|
||||||
|
├── quickstart.md # Phase 1 output
|
||||||
|
└── tasks.md # Phase 2 output (/speckit.tasks)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/web/src/
|
||||||
|
├── components/
|
||||||
|
│ ├── action-bar.tsx # MODIFY: overhaul with batch queue, custom fields, stat block button
|
||||||
|
│ └── bestiary-search.tsx # MODIFY or REMOVE: functionality merges into action-bar
|
||||||
|
├── hooks/
|
||||||
|
│ └── use-encounter.ts # EXISTING: addFromBestiary called N times for batch
|
||||||
|
├── App.tsx # MODIFY: pass stat block view callback to action bar
|
||||||
|
packages/domain/src/
|
||||||
|
├── add-combatant.ts # EXISTING: no changes
|
||||||
|
├── auto-number.ts # EXISTING: handles name deduplication for batch adds
|
||||||
|
└── types.ts # EXISTING: Combatant already has initiative?, ac?, maxHp?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: All changes are within `apps/web/src/components/` (adapter layer). The action bar component is the primary modification target. The existing `BestiarySearch` component's dropdown functionality will be integrated directly into the action bar's unified search flow. No new packages or layers needed.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
> No constitution violations. Table intentionally left empty.
|
||||||
39
specs/036-bottombar-overhaul/quickstart.md
Normal file
39
specs/036-bottombar-overhaul/quickstart.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Quickstart: Bottom Bar Overhaul
|
||||||
|
|
||||||
|
## Key Files to Modify
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `apps/web/src/components/action-bar.tsx` | Primary target: overhaul with batch queue, custom fields, stat block button |
|
||||||
|
| `apps/web/src/hooks/use-encounter.ts` | Extend `addCombatant` to accept optional fields (initiative, AC, maxHp) |
|
||||||
|
| `apps/web/src/App.tsx` | Wire stat block view callback to action bar |
|
||||||
|
| `apps/web/src/components/bestiary-search.tsx` | Likely removable after merging into unified action bar flow |
|
||||||
|
|
||||||
|
## Key Files to Read (context)
|
||||||
|
|
||||||
|
| File | Why |
|
||||||
|
|------|-----|
|
||||||
|
| `packages/domain/src/types.ts` | Combatant type definition |
|
||||||
|
| `packages/domain/src/add-combatant.ts` | Domain add function (not modified, but understand contract) |
|
||||||
|
| `packages/domain/src/auto-number.ts` | Name deduplication logic for batch adds |
|
||||||
|
| `apps/web/src/hooks/use-bestiary.ts` | SearchResult type, search function |
|
||||||
|
| `apps/web/src/components/stat-block-panel.tsx` | Stat block panel for viewer integration |
|
||||||
|
|
||||||
|
## Implementation Approach
|
||||||
|
|
||||||
|
1. **Batch add**: Add `QueuedCreature` state (`{ result, count } | null`) to action bar. On dropdown entry click: if same entry, increment count; if different, replace. On confirm/Enter, loop `addFromBestiary(result)` N times.
|
||||||
|
|
||||||
|
2. **Custom fields**: When `suggestions.length === 0` and query is non-empty, show initiative/AC/maxHP inputs. Extend `onAddCombatant` to accept optional stats object. In `use-encounter.ts`, patch the new combatant with provided values after creation.
|
||||||
|
|
||||||
|
3. **Stat block viewer**: Accept `onViewStatBlock(result: SearchResult)` prop. Repurpose the search button icon to trigger it with the currently highlighted dropdown entry. Wire in App.tsx to derive `CreatureId` and open stat block panel.
|
||||||
|
|
||||||
|
4. **Unified flow**: Remove `searchOpen` toggle state. The input field always shows bestiary suggestions inline. Remove or deprecate `BestiarySearch` component.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter web dev # Dev server at localhost:5173
|
||||||
|
pnpm test # Run all tests
|
||||||
|
pnpm check # Full merge gate (must pass before commit)
|
||||||
|
pnpm vitest run <file> # Run single test file
|
||||||
|
```
|
||||||
48
specs/036-bottombar-overhaul/research.md
Normal file
48
specs/036-bottombar-overhaul/research.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Research: Bottom Bar Overhaul
|
||||||
|
|
||||||
|
## R-001: Batch Add via Existing Domain API
|
||||||
|
|
||||||
|
**Decision**: Call `addFromBestiary(result)` in a loop N times (once per queued count) rather than creating a new batch domain function.
|
||||||
|
|
||||||
|
**Rationale**: The existing `addFromBestiary` in `use-encounter.ts` already handles name auto-numbering (via `resolveCreatureName`), HP/AC assignment, and creatureId derivation. Calling it N times is correct because each call resolves the creature name against the updated combatant list, producing proper sequential numbering (e.g., "Goblin 1", "Goblin 2", "Goblin 3").
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- New domain-level `addBatchCombatants` function: Rejected because auto-numbering depends on seeing previously-added names, which the loop approach already handles. A batch function would duplicate logic.
|
||||||
|
- Application-layer batch use case: Rejected — no persistence coordination needed; the hook already manages state.
|
||||||
|
|
||||||
|
## R-002: Custom Creature Optional Fields (Initiative, AC, Max HP)
|
||||||
|
|
||||||
|
**Decision**: Extend the existing `onAddCombatant` callback (or create a sibling) to accept optional `{ initiative?: number; ac?: number; maxHp?: number }` alongside the name. The `use-encounter` hook's `addCombatant` flow will be extended to apply these fields to the newly created combatant.
|
||||||
|
|
||||||
|
**Rationale**: The domain `Combatant` type already has `initiative?`, `ac?`, and `maxHp?` fields. The domain `addCombatant` function creates a combatant with just `id` and `name`; the hook currently patches `currentHp`/`maxHp`/`ac` after creation for bestiary creatures. The same pattern works for custom creatures.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Extend domain `addCombatant` to accept optional fields: Rejected — the domain function is minimal by design (pure add with name validation). Post-creation patching is the established pattern.
|
||||||
|
- New domain function `addCustomCombatant`: Rejected — unnecessary complexity for fields that are just optional patches on the existing type.
|
||||||
|
|
||||||
|
## R-003: Stat Block Preview from Dropdown
|
||||||
|
|
||||||
|
**Decision**: Pass a `onViewStatBlock(result: SearchResult)` callback from App.tsx through ActionBar. When triggered, it derives the `CreatureId` from the search result (same `${source}:${slug}` pattern used in `addFromBestiary`) and opens the stat block panel for that creature.
|
||||||
|
|
||||||
|
**Rationale**: The stat block panel infrastructure already exists and accepts a `CreatureId`. The only new wiring is a callback from the dropdown row to the panel opener.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Inline stat block preview in the dropdown: Rejected — the dropdown is compact and the stat block panel already handles full rendering, source fetching, and caching.
|
||||||
|
|
||||||
|
## R-004: Unified Search Flow vs Separate BestiarySearch Component
|
||||||
|
|
||||||
|
**Decision**: Merge the separate `BestiarySearch` component's functionality into the action bar's existing inline search. Remove the toggle between "form mode" and "search mode" (`searchOpen` state). The action bar always shows a single input field that serves both purposes.
|
||||||
|
|
||||||
|
**Rationale**: The current action bar has two modes — a name input with inline suggestions, and a separate full `BestiarySearch` overlay toggled by the search button. The spec calls for a unified flow where the single input field shows bestiary results as you type (already working via `suggestions`), with click-to-queue behavior replacing the immediate add-on-click. The separate `BestiarySearch` component becomes redundant.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Keep both modes: Rejected — the spec explicitly merges the flows (dropdown opens on type, search button becomes stat block viewer).
|
||||||
|
|
||||||
|
## R-005: Queue State Management
|
||||||
|
|
||||||
|
**Decision**: Queue state is local React state in the action bar component: `{ result: SearchResult; count: number } | null`. No hook or context needed.
|
||||||
|
|
||||||
|
**Rationale**: The queue is purely ephemeral and scoped to the action bar's interaction lifecycle. It resets on confirm, escape, or when the queued creature leaves the search results.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Custom hook for queue state: Rejected — the state is simple (one nullable object) and doesn't need to be shared.
|
||||||
118
specs/036-bottombar-overhaul/spec.md
Normal file
118
specs/036-bottombar-overhaul/spec.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# Feature Specification: Bottom Bar Overhaul
|
||||||
|
|
||||||
|
**Feature Branch**: `036-bottombar-overhaul`
|
||||||
|
**Created**: 2026-03-11
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Overhaul bottom bar: batch add, stat block view, custom creature fields"
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Batch Add Predefined Creatures (Priority: P1)
|
||||||
|
|
||||||
|
As a DM, I want to quickly add multiple copies of the same creature from the bestiary so I can set up encounters with groups of identical monsters without repetitive searching and clicking.
|
||||||
|
|
||||||
|
**Why this priority**: This is the most impactful workflow improvement — setting up encounters with multiple identical creatures (e.g., 4 goblins) is currently tedious, requiring one search-and-add per creature.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by searching for a creature, clicking the dropdown entry multiple times to increment count, then confirming — delivers batch-add value independently.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the search field is focused and the user types a query, **When** results appear in the dropdown, **Then** the dropdown opens automatically as the user types.
|
||||||
|
2. **Given** the dropdown is showing results, **When** the user clicks on a creature entry, **Then** a count badge showing "1" and a confirm button appear on that row.
|
||||||
|
3. **Given** a creature entry already shows a count of N, **When** the user clicks that same entry again, **Then** the count increments to N+1.
|
||||||
|
4. **Given** the user has queued creature A with count 3 and then clicks creature B, **Then** creature A's queue is replaced by creature B with count 1 (only one creature type can be queued at a time).
|
||||||
|
5. **Given** a creature is queued with count N, **When** the user clicks the confirm button on that row, **Then** N copies of that creature are added to combat and the queue resets.
|
||||||
|
6. **Given** a creature is queued with count N, **When** the user presses Enter, **Then** N copies of that creature are added to combat and the queue resets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Custom Creature with Optional Fields (Priority: P2)
|
||||||
|
|
||||||
|
As a DM, I want to type a custom creature name that doesn't match the bestiary and optionally provide initiative, AC, and max HP values so I can add homebrew or improvised creatures with pre-filled stats.
|
||||||
|
|
||||||
|
**Why this priority**: Supports the common case of adding homebrew or one-off creatures with known stats, reducing the need to manually edit each field after adding.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by typing a non-matching name, optionally filling in the extra fields, and adding — the creature appears in combat with the provided values.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the user types a name that has no bestiary match, **When** the dropdown shows no results (or the query is too short for bestiary matches), **Then** optional input fields for initiative, AC, and max HP appear below or beside the name field.
|
||||||
|
2. **Given** the optional fields are visible, **When** the user leaves all optional fields empty and submits, **Then** the creature is added with only the name (no stats pre-filled).
|
||||||
|
3. **Given** the optional fields are visible, **When** the user fills in some or all fields and submits, **Then** the creature is added with the provided values applied.
|
||||||
|
4. **Given** the optional fields are visible, **Then** each field has a clear label (e.g., "Initiative", "AC", "Max HP") so the user knows what each input is for.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Stat Block Viewer from Dropdown (Priority: P3)
|
||||||
|
|
||||||
|
As a DM, I want to preview a creature's stat block directly from the search dropdown so I can review creature details before deciding to add them to the encounter.
|
||||||
|
|
||||||
|
**Why this priority**: Enhances the search experience by allowing stat block previews without committing to adding the creature, but is not essential for the core add-creature workflow.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by searching for a creature in the dropdown, triggering the stat block view for the highlighted/selected entry, and verifying the stat block panel opens with the correct creature data.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the dropdown is showing bestiary results, **When** the user clicks the search/view button (repurposed from the current search icon), **Then** the stat block for the currently focused/highlighted creature in the dropdown opens in the stat block panel.
|
||||||
|
2. **Given** no creature is focused in the dropdown, **When** the user clicks the stat block view button, **Then** nothing happens (button is disabled or no-op).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 4 - Improved Search Hint Text (Priority: P3)
|
||||||
|
|
||||||
|
As a DM, I want the search field to have clear, action-oriented placeholder text so I immediately understand its purpose.
|
||||||
|
|
||||||
|
**Why this priority**: Small UX polish that improves discoverability but does not change functionality.
|
||||||
|
|
||||||
|
**Independent Test**: Can be verified by inspecting the placeholder text on the search input field.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the bottom bar is visible, **When** the search field is empty, **Then** the placeholder reads action-oriented text such as "Search creatures to add...".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when the user queues a creature, then changes the search query? The queue resets when the queued creature is no longer visible in the results.
|
||||||
|
- What happens when the user queues a count and then presses Escape? The queue resets and the dropdown closes.
|
||||||
|
- What happens if the user types a name that initially matches the bestiary but then extends it to no longer match? The optional custom fields appear once there are no bestiary matches.
|
||||||
|
- What happens when the user submits a custom creature with non-numeric values in the optional fields? Invalid numeric input is ignored (treated as empty).
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The search field placeholder MUST display action-oriented hint text (e.g., "Search creatures to add...").
|
||||||
|
- **FR-002**: The dropdown MUST open automatically when the user types at least 2 characters.
|
||||||
|
- **FR-003**: Clicking a bestiary dropdown entry MUST show a count badge (starting at 1) and a confirm button on that row.
|
||||||
|
- **FR-004**: Clicking the same entry again MUST increment the count by 1.
|
||||||
|
- **FR-005**: Only one creature type MAY be queued at a time; selecting a different creature MUST replace the current queue.
|
||||||
|
- **FR-006**: Confirming the queue MUST add N copies of the selected creature to combat and reset the queue state.
|
||||||
|
- **FR-007**: The existing search button MUST be repurposed to open the stat block for the currently focused/selected creature in the dropdown.
|
||||||
|
- **FR-008**: When no bestiary match exists for the typed name, the system MUST show optional input fields for initiative, AC, and max HP.
|
||||||
|
- **FR-009**: Custom creatures MUST be addable with or without the optional fields filled in.
|
||||||
|
- **FR-010**: Each optional field MUST have a visible label indicating its purpose.
|
||||||
|
- **FR-011**: Pressing Enter with a queued creature MUST behave the same as clicking the confirm button.
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **Queued Creature**: A transient UI-only state representing a bestiary creature selected for batch-add, containing the creature reference and a count (1+).
|
||||||
|
- **Custom Creature Input**: A transient UI-only state representing a user-typed creature name with optional initiative (number), AC (number), and max HP (number) fields.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: A DM can add 4 identical creatures to combat in 3 steps (type search query, click creature entry 4 times to set count, confirm) — down from 4 separate search-and-add cycles.
|
||||||
|
- **SC-002**: Custom creatures can be added with pre-filled stats in a single form submission without needing to edit stats after adding.
|
||||||
|
- **SC-003**: Stat block preview is accessible directly from the search dropdown without leaving the add-creature flow.
|
||||||
|
- **SC-004**: All existing add-creature functionality continues to work (no regression in custom name or bestiary-based adding).
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The batch-add count has no upper limit; the user can click as many times as they want to increment the count.
|
||||||
|
- The optional fields for custom creatures (initiative, AC, max HP) accept numeric input only; non-numeric input is ignored.
|
||||||
|
- The stat block viewer reuses the existing stat block panel infrastructure (no new panel type needed).
|
||||||
|
- "Focused/selected creature" for the stat block view button refers to the keyboard-highlighted or last-clicked creature in the dropdown.
|
||||||
|
- The batch-add queue is purely ephemeral UI state — it is not persisted.
|
||||||
156
specs/036-bottombar-overhaul/tasks.md
Normal file
156
specs/036-bottombar-overhaul/tasks.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# Tasks: Bottom Bar Overhaul
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/036-bottombar-overhaul/`
|
||||||
|
**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, quickstart.md
|
||||||
|
|
||||||
|
**Tests**: Not explicitly requested in the feature specification. Tests omitted.
|
||||||
|
|
||||||
|
**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: Foundational (Unified Search Flow)
|
||||||
|
|
||||||
|
**Purpose**: Remove the dual-mode search (form + BestiarySearch overlay) and establish a single unified input that always shows inline bestiary suggestions. This unblocks all user stories.
|
||||||
|
|
||||||
|
- [x] T001 Refactor `ActionBar` to remove `searchOpen` state and the `BestiarySearch` toggle in `apps/web/src/components/action-bar.tsx` — the component should always render the form with inline suggestions dropdown (the existing `suggestions` + `handleNameChange` flow). Remove the conditional branch that renders `<BestiarySearch>`. Remove the `Search` icon button that toggled `searchOpen`. Keep the bulk import button unchanged.
|
||||||
|
- [x] T002 Update `ActionBarProps` interface in `apps/web/src/components/action-bar.tsx` — the `onSelectCreature` behavior changes: clicking a dropdown entry no longer immediately adds. For now, keep the existing click-to-add behavior working (it will be replaced in US1). Ensure the inline dropdown still appears when typing 2+ characters and keyboard navigation (ArrowUp/Down/Enter/Escape) still works.
|
||||||
|
- [x] T003 Update placeholder text on the name input from "Combatant name" to "Search creatures to add..." in `apps/web/src/components/action-bar.tsx` (FR-001).
|
||||||
|
|
||||||
|
**Checkpoint**: Action bar shows a single input field with inline bestiary dropdown. No more toggling between form and search modes. Existing add-on-click still works.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: User Story 1 - Batch Add Predefined Creatures (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Click a bestiary dropdown entry to queue it with a count badge; click again to increment; confirm to add N copies to combat.
|
||||||
|
|
||||||
|
**Independent Test**: Search for a creature, click entry multiple times to increment count, confirm — N copies appear in combat with auto-numbered names.
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T004 [US1] Add `QueuedCreature` state to `ActionBar` in `apps/web/src/components/action-bar.tsx` — add state `const [queued, setQueued] = useState<{ result: SearchResult; count: number } | null>(null)`. When a dropdown entry is clicked: if same creature (match by `source` + `name`), increment count; if different creature, replace with count 1. Reset queued state when query changes such that the queued creature is no longer in the results list.
|
||||||
|
- [x] T005 [US1] Render count badge and confirm button on the queued dropdown row in `apps/web/src/components/action-bar.tsx` — in the suggestions `<ul>`, for the queued entry's row, show a count badge (e.g., a small rounded pill with the number) and a confirm button (e.g., a check icon or "Add" text button). Non-queued rows remain clickable as normal (first click queues them). Style the queued row distinctly (e.g., highlighted background).
|
||||||
|
- [x] T006 [US1] Implement batch confirm logic in `apps/web/src/components/action-bar.tsx` — when confirm button is clicked or Enter is pressed while a creature is queued: call `onAddFromBestiary(queued.result)` in a loop `queued.count` times, then reset queued state, clear the input, and close the dropdown. Update `handleKeyDown` so Enter with a queued creature triggers confirm instead of the default form submit.
|
||||||
|
- [x] T007 [US1] Handle edge cases for queue state in `apps/web/src/components/action-bar.tsx` — Escape clears queued state and closes dropdown. When query changes, check if the queued creature's `source:name` is still in the current `suggestions` array; if not, clear the queue.
|
||||||
|
|
||||||
|
**Checkpoint**: Batch add flow fully works. Search → click entry (count badge appears) → click again (count increments) → confirm (N copies added). Auto-numbering handled by existing `addFromBestiary`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 2 - Custom Creature with Optional Fields (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: When the typed name has no bestiary match, show optional initiative/AC/max HP fields so custom creatures can be added with pre-filled stats.
|
||||||
|
|
||||||
|
**Independent Test**: Type a name with no bestiary match, fill in optional fields, submit — creature appears in combat with provided values.
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T008 [US2] Extend `onAddCombatant` callback signature in `apps/web/src/components/action-bar.tsx` — change `ActionBarProps.onAddCombatant` from `(name: string) => void` to `(name: string, opts?: { initiative?: number; ac?: number; maxHp?: number }) => void`. Update `handleAdd` to collect and pass the optional fields.
|
||||||
|
- [x] T009 [US2] Extend `addCombatant` in `apps/web/src/hooks/use-encounter.ts` to accept and apply optional fields — after the domain `addCombatant` call creates the combatant, patch `initiative`, `ac`, `maxHp`, and `currentHp` (set to `maxHp` when provided) on the new combatant, following the same post-creation patching pattern used by `addFromBestiary`.
|
||||||
|
- [x] T010 [US2] Update `App.tsx` to pass the extended `onAddCombatant` through to `ActionBar` in `apps/web/src/App.tsx` — ensure the callback signature matches the new optional fields parameter.
|
||||||
|
- [x] T011 [US2] Render optional fields UI in `apps/web/src/components/action-bar.tsx` — when `query.length >= 2` and `suggestions.length === 0` (no bestiary match, using the same 2-char threshold as bestiary search per FR-002), show three small labeled input fields below the name input: "Initiative" (number), "AC" (number), "Max HP" (number). Use `<Input type="number">` or text inputs with numeric parsing. Add local state for the three field values (strings, parsed to numbers on submit). Clear field state when bestiary results appear or after submit.
|
||||||
|
- [x] T012 [US2] Implement numeric parsing and submit for custom fields in `apps/web/src/components/action-bar.tsx` — in `handleAdd`, parse each field value with `Number()` or `parseInt`; if result is `NaN` or empty string, omit that field. Pass valid values to `onAddCombatant(name, { initiative, ac, maxHp })`. Reset all field inputs after submit.
|
||||||
|
|
||||||
|
**Checkpoint**: Custom creatures can be added with optional pre-filled initiative, AC, and max HP. Fields are clearly labeled and only appear when no bestiary match exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 3 - Stat Block Viewer from Dropdown (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: A button in the action bar opens the stat block panel for the currently focused/highlighted creature in the dropdown.
|
||||||
|
|
||||||
|
**Independent Test**: Search for a creature, highlight an entry, click the stat block view button — stat block panel opens showing that creature's data.
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [x] T013 [US3] Add `onViewStatBlock` prop to `ActionBarProps` in `apps/web/src/components/action-bar.tsx` — add `onViewStatBlock?: (result: SearchResult) => void` to the props interface. Add a button (using the `Search` or `Eye` icon from Lucide) next to the input that is enabled only when `suggestionIndex >= 0` (a creature is highlighted in the dropdown). On click, call `onViewStatBlock(suggestions[suggestionIndex])`.
|
||||||
|
- [x] T014 [US3] Wire `onViewStatBlock` callback in `apps/web/src/App.tsx` — create a handler that takes a `SearchResult`, derives the `CreatureId` using the same `${source}:${slug}` pattern from `addFromBestiary` in `use-encounter.ts`, and opens the stat block panel by setting the browse creature ID state. Pass this handler to `ActionBar`.
|
||||||
|
|
||||||
|
**Checkpoint**: Stat block preview works from the search dropdown. The search icon button now opens the stat block for the highlighted creature instead of toggling a separate search mode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Cleanup, remove dead code, ensure quality gates pass.
|
||||||
|
|
||||||
|
- [x] T015 [P] Remove `apps/web/src/components/bestiary-search.tsx` if no longer imported anywhere — verify with a grep for `bestiary-search` or `BestiarySearch` across the codebase. Remove the file and any unused imports.
|
||||||
|
- [x] T016 [P] Run `pnpm check` to verify all quality gates pass (Knip unused code, Biome lint/format, typecheck, tests, jscpd).
|
||||||
|
- [x] T017 Update CLAUDE.md active technologies section for this feature branch in `/Users/lukas.richter/projects/initiative/CLAUDE.md`.
|
||||||
|
- [x] T018 Update README.md if it documents the add-creature workflow — batch-add and custom creature fields are new user-facing capabilities (constitution: README MUST be updated when user-facing capabilities change).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Foundational (Phase 1)**: No dependencies — start immediately. BLOCKS all user stories.
|
||||||
|
- **US1 (Phase 2)**: Depends on Phase 1 completion.
|
||||||
|
- **US2 (Phase 3)**: Depends on Phase 1 completion. Independent of US1.
|
||||||
|
- **US3 (Phase 4)**: Depends on Phase 1 completion. Independent of US1 and US2.
|
||||||
|
- **Polish (Phase 5)**: Depends on all user stories being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1 (P1)**: Can start after Phase 1. No cross-story dependencies.
|
||||||
|
- **US2 (P2)**: Can start after Phase 1. No cross-story dependencies (different code paths — bestiary match vs. no match).
|
||||||
|
- **US3 (P3)**: Can start after Phase 1. No cross-story dependencies (separate button + callback).
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Tasks are ordered sequentially within each story (state → UI → logic → edge cases).
|
||||||
|
- US2 T008-T010 can be parallelized (props change, hook change, App.tsx wiring).
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- After Phase 1: US1, US2, and US3 can all proceed in parallel (different interaction paths, different code concerns).
|
||||||
|
- Within US2: T008, T009, T010 touch different files and can run in parallel.
|
||||||
|
- Within Phase 5: T015 and T016 can run in parallel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: After Phase 1
|
||||||
|
|
||||||
|
```text
|
||||||
|
# All three user stories can start simultaneously:
|
||||||
|
Stream A (US1): T004 → T005 → T006 → T007
|
||||||
|
Stream B (US2): T008 + T009 + T010 (parallel) → T011 → T012
|
||||||
|
Stream C (US3): T013 → T014
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Foundational (unified search flow)
|
||||||
|
2. Complete Phase 2: User Story 1 (batch add)
|
||||||
|
3. **STOP and VALIDATE**: Test batch add independently
|
||||||
|
4. This alone delivers the highest-value improvement
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Phase 1 (Foundational) → Unified input field works
|
||||||
|
2. + US1 (Batch Add) → MVP! Test independently
|
||||||
|
3. + US2 (Custom Fields) → Test independently
|
||||||
|
4. + US3 (Stat Block Viewer) → Test independently
|
||||||
|
5. Phase 5 (Polish) → Clean up, quality gates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All changes are adapter-layer only (apps/web). No domain or application package changes except extending the hook callback in T009.
|
||||||
|
- The `BestiarySearch` component becomes dead code after Phase 1 — removed in Phase 5.
|
||||||
|
- Batch add relies on calling existing `addFromBestiary` N times in a loop — auto-numbering is handled by `resolveCreatureName` in the hook.
|
||||||
|
- Custom creature optional fields use post-creation patching, same pattern as bestiary creatures.
|
||||||
|
- Queue state (`QueuedCreature`) and custom field state (`CustomCreatureFields`) are ephemeral React state — no persistence.
|
||||||
Reference in New Issue
Block a user