diff --git a/CLAUDE.md b/CLAUDE.md index 954a4cc..8c441ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) - 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) +- 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 - 032-inline-confirm-buttons: Added TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Tailwind CSS v4, Lucide React, class-variance-authority (cva) diff --git a/README.md b/README.md index f1f2077..59a2de7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e ## 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 - **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 diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 4143274..d35909a 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -107,6 +107,16 @@ export function App() { rollAllInitiativeUseCase(makeStore(), rollDice, 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(() => { setBulkImportMode(true); setSelectedCreatureId(null); @@ -234,6 +244,7 @@ export function App() { onAddFromBestiary={handleAddFromBestiary} bestiarySearch={search} bestiaryLoaded={isLoaded} + onViewStatBlock={handleViewStatBlock} onBulkImport={handleBulkImport} bulkImportDisabled={bulkImport.state.status === "loading"} /> diff --git a/apps/web/src/components/action-bar.tsx b/apps/web/src/components/action-bar.tsx index e7da32a..0b52eea 100644 --- a/apps/web/src/components/action-bar.tsx +++ b/apps/web/src/components/action-bar.tsx @@ -1,61 +1,136 @@ -import { Import, Search } from "lucide-react"; -import { type FormEvent, useState } from "react"; +import { Check, Eye, Import, Minus, Plus } from "lucide-react"; +import { type FormEvent, useEffect, useRef, useState } from "react"; import type { SearchResult } from "../hooks/use-bestiary.js"; -import { BestiarySearch } from "./bestiary-search.js"; import { Button } from "./ui/button.js"; import { Input } from "./ui/input.js"; +interface QueuedCreature { + result: SearchResult; + count: number; +} + interface ActionBarProps { - onAddCombatant: (name: string) => void; + onAddCombatant: ( + name: string, + opts?: { initiative?: number; ac?: number; maxHp?: number }, + ) => void; onAddFromBestiary: (result: SearchResult) => void; bestiarySearch: (query: string) => SearchResult[]; bestiaryLoaded: boolean; + onViewStatBlock?: (result: SearchResult) => void; onBulkImport?: () => void; bulkImportDisabled?: boolean; } +function creatureKey(r: SearchResult): string { + return `${r.source}:${r.name}`; +} + export function ActionBar({ onAddCombatant, onAddFromBestiary, bestiarySearch, bestiaryLoaded, + onViewStatBlock, onBulkImport, bulkImportDisabled, }: ActionBarProps) { const [nameInput, setNameInput] = useState(""); - const [searchOpen, setSearchOpen] = useState(false); const [suggestions, setSuggestions] = useState([]); const [suggestionIndex, setSuggestionIndex] = useState(-1); + const [queued, setQueued] = useState(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([]); + const [viewerIndex, setViewerIndex] = useState(-1); + const viewerRef = useRef(null); + const viewerInputRef = useRef(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) => { e.preventDefault(); + if (queued) { + confirmQueued(); + 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(""); setSuggestions([]); + clearCustomFields(); }; const handleNameChange = (value: string) => { setNameInput(value); setSuggestionIndex(-1); + let newSuggestions: SearchResult[] = []; if (value.length >= 2) { - setSuggestions(bestiarySearch(value)); + newSuggestions = bestiarySearch(value); + setSuggestions(newSuggestions); } else { 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) => { - onAddFromBestiary(result); - setSearchOpen(false); - setNameInput(""); - setSuggestions([]); + const handleClickSuggestion = (result: SearchResult) => { + const key = creatureKey(result); + if (queued && creatureKey(queued.result) === key) { + setQueued({ ...queued, count: queued.count + 1 }); + } else { + setQueued({ result, count: 1 }); + } }; - const handleSelectSuggestion = (result: SearchResult) => { - onAddFromBestiary(result); - setNameInput(""); - setSuggestions([]); + const handleEnter = () => { + if (queued) { + confirmQueued(); + } else if (suggestionIndex >= 0) { + handleClickSuggestion(suggestions[suggestionIndex]); + } }; const handleKeyDown = (e: React.KeyboardEvent) => { @@ -67,91 +142,276 @@ export function ActionBar({ } else if (e.key === "ArrowUp") { e.preventDefault(); setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1)); - } else if (e.key === "Enter" && suggestionIndex >= 0) { + } else if (e.key === "Enter") { e.preventDefault(); - handleSelectSuggestion(suggestions[suggestionIndex]); + handleEnter(); } else if (e.key === "Escape") { + setQueued(null); setSuggestionIndex(-1); 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 (
- {searchOpen ? ( - setSearchOpen(false)} - searchFn={bestiarySearch} - /> - ) : ( -
-
- handleNameChange(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Combatant name" - className="max-w-xs" - /> - {suggestions.length > 0 && ( -
-
    - {suggestions.map((result, i) => ( -
  • + +
    + handleNameChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search creatures to add..." + className="max-w-xs" + /> + {suggestions.length > 0 && ( +
    +
      + {suggestions.map((result, i) => { + const key = creatureKey(result); + const isQueued = + queued !== null && creatureKey(queued.result) === key; + return ( +
    • + + {queued.count} + + + + + ) : ( + result.sourceDisplayName + )}
    • - ))} -
    + ); + })} +
+
+ )} +
+ {nameInput.length >= 2 && suggestions.length === 0 && ( +
+ setCustomInit(e.target.value)} + placeholder="Init" + className="w-16 text-center" + /> + setCustomAc(e.target.value)} + placeholder="AC" + className="w-16 text-center" + /> + setCustomMaxHp(e.target.value)} + placeholder="MaxHP" + className="w-18 text-center" + /> +
+ )} + + {bestiaryLoaded && onViewStatBlock && ( +
+ + {viewerOpen && ( +
+
+ handleViewerQueryChange(e.target.value)} + onKeyDown={handleViewerKeyDown} + placeholder="Search stat blocks..." + className="w-full" + /> +
+ {viewerResults.length > 0 && ( +
    + {viewerResults.map((result, i) => ( +
  • + +
  • + ))} +
+ )} + {viewerQuery.length >= 2 && viewerResults.length === 0 && ( +
+ No creatures found +
+ )}
)}
- - {bestiaryLoaded && ( - <> - - {onBulkImport && ( - - )} - - )} -
- )} + )} +
); } diff --git a/apps/web/src/components/bestiary-search.tsx b/apps/web/src/components/bestiary-search.tsx deleted file mode 100644 index a0ecb42..0000000 --- a/apps/web/src/components/bestiary-search.tsx +++ /dev/null @@ -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(null); - const containerRef = useRef(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 ( -
-
- - setQuery(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Search bestiary..." - className="flex-1" - /> - -
- - {query.length >= 2 && ( -
- {results.length === 0 ? ( -
- No creatures found -
- ) : ( -
    - {results.map((result, i) => ( -
  • - -
  • - ))} -
- )} -
- )} -
- ); -} diff --git a/apps/web/src/hooks/use-encounter.ts b/apps/web/src/hooks/use-encounter.ts index 6dd0242..20ebab2 100644 --- a/apps/web/src/hooks/use-encounter.ts +++ b/apps/web/src/hooks/use-encounter.ts @@ -65,6 +65,33 @@ function deriveNextId(encounter: Encounter): number { return max; } +interface CombatantOpts { + initiative?: number; + ac?: number; + maxHp?: number; +} + +function applyCombatantOpts( + makeStore: () => EncounterStore, + id: ReturnType, + 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() { const [encounter, setEncounter] = useState(initializeEncounter); const [events, setEvents] = useState([]); @@ -108,7 +135,7 @@ export function useEncounter() { const nextId = useRef(deriveNextId(encounter)); const addCombatant = useCallback( - (name: string) => { + (name: string, opts?: CombatantOpts) => { const id = combatantId(`c-${++nextId.current}`); const result = addCombatantUseCase(makeStore(), id, name); @@ -116,6 +143,13 @@ export function useEncounter() { return; } + if (opts) { + const optEvents = applyCombatantOpts(makeStore, id, opts); + if (optEvents.length > 0) { + setEvents((prev) => [...prev, ...optEvents]); + } + } + setEvents((prev) => [...prev, ...result]); }, [makeStore], @@ -279,15 +313,14 @@ export function useEncounter() { .replace(/(^-|-$)/g, ""); 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 updated = { + store.save({ ...currentEncounter, combatants: currentEncounter.combatants.map((c) => c.id === id ? { ...c, creatureId: cId } : c, ), - }; - setEncounter(updated); + }); setEvents((prev) => [...prev, ...addResult]); }, diff --git a/specs/036-bottombar-overhaul/checklists/requirements.md b/specs/036-bottombar-overhaul/checklists/requirements.md new file mode 100644 index 0000000..d6d0676 --- /dev/null +++ b/specs/036-bottombar-overhaul/checklists/requirements.md @@ -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`. diff --git a/specs/036-bottombar-overhaul/data-model.md b/specs/036-bottombar-overhaul/data-model.md new file mode 100644 index 0000000..036efa5 --- /dev/null +++ b/specs/036-bottombar-overhaul/data-model.md @@ -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] +``` diff --git a/specs/036-bottombar-overhaul/plan.md b/specs/036-bottombar-overhaul/plan.md new file mode 100644 index 0000000..c9412b0 --- /dev/null +++ b/specs/036-bottombar-overhaul/plan.md @@ -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. diff --git a/specs/036-bottombar-overhaul/quickstart.md b/specs/036-bottombar-overhaul/quickstart.md new file mode 100644 index 0000000..5341f85 --- /dev/null +++ b/specs/036-bottombar-overhaul/quickstart.md @@ -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 # Run single test file +``` diff --git a/specs/036-bottombar-overhaul/research.md b/specs/036-bottombar-overhaul/research.md new file mode 100644 index 0000000..87e0290 --- /dev/null +++ b/specs/036-bottombar-overhaul/research.md @@ -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. diff --git a/specs/036-bottombar-overhaul/spec.md b/specs/036-bottombar-overhaul/spec.md new file mode 100644 index 0000000..0c279c0 --- /dev/null +++ b/specs/036-bottombar-overhaul/spec.md @@ -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. diff --git a/specs/036-bottombar-overhaul/tasks.md b/specs/036-bottombar-overhaul/tasks.md new file mode 100644 index 0000000..899cf41 --- /dev/null +++ b/specs/036-bottombar-overhaul/tasks.md @@ -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 ``. 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 `
    `, 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 `` 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.