Files
initiative/specs/004-bestiary/spec.md
Lukas 613bb70065 Consolidate 36 per-change specs into 4 feature-level specs and align workflow
Replace granular change-level specs (001–036) with living feature specs:
- 001-combatant-management (CRUD, persistence, clear, confirm buttons)
- 002-turn-tracking (rounds, turn order, advance/retreat, top bar)
- 003-combatant-state (HP, AC, conditions, concentration, initiative)
- 004-bestiary (search, stat blocks, source management, panel UX)

Workflow changes:
- Add /integrate-issue command (replaces /issue-to-spec) for routing
  issues to existing specs or handing off to /speckit.specify
- Update /sync-issue to list specs instead of requiring feature branch
- Update /write-issue to reference /integrate-issue
- Add RPI skills (research, plan, implement) to .claude/skills/
- Create docs/agents/ for RPI artifacts (research reports, plans)
- Remove update-agent-context.sh call from /speckit.plan
- Update CLAUDE.md with proportional scope-based workflow table
- Bump constitution to 3.0.0 (specs describe features, not changes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:33:27 +01:00

302 lines
31 KiB
Markdown

# Feature Specification: Bestiary
**Feature Branch**: `004-bestiary`
**Created**: 2026-03-06
**Status**: Implemented
---
## Overview
The Bestiary feature provides creature search across a pre-indexed library of 3,312+ creatures from 102+ D&D sources, stat block display for full creature reference during combat, source data management via on-demand fetch or file upload, and a dual-panel UX with fold/unfold and pin capabilities. Creatures can be added individually or in batch from a search dropdown, with stats pre-filled from the index.
The architecture uses a two-tier design: a lightweight search index (`data/bestiary/index.json`) shipped with the app containing mechanical facts for all creatures, and full stat block data loaded on-demand at the source level, normalized, and cached in IndexedDB.
**Structure**: This spec is organized by topic area. Each topic section contains its own user scenarios, requirements, and edge cases.
---
## Search & Discovery
### User Stories
**US-S1 — Search and Add a Creature (P1)**
As a DM running an encounter, I want to search for a creature by name in the bestiary so that I can quickly add it as a combatant with its stats pre-filled (name, HP, AC), saving me from manually entering data.
A search field in the bottom bar accepts typed queries. Matching creatures from the pre-shipped index appear in a dropdown, each labeled with its source display name (e.g., "Goblin (Monster Manual (2025))"). Selecting a creature adds it as a combatant — name, HP, AC, and initiative modifier are populated directly from the index without any network fetch. The search field displays action-oriented placeholder text (e.g., "Search creatures to add...").
**US-S2 — Batch Add Multiple Copies of a Creature (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.
Clicking a dropdown entry once shows a count badge (starting at 1) and a confirm button on that row. Clicking the same entry again increments the count. Confirming adds N copies of that creature to combat and resets the queue. Only one creature type may be queued at a time.
**US-S3 — Add a Custom Creature with Optional Stats (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.
When the search input has no bestiary matches (or fewer than 2 characters typed), optional input fields for initiative, AC, and max HP appear. The creature is addable with or without these fields filled in.
### Requirements
- **FR-001**: The app MUST ship a pre-generated search index (`data/bestiary/index.json`) containing creature name, source code, AC, HP average, DEX score, CR, initiative proficiency multiplier, size code, and creature type for all indexed creatures.
- **FR-002**: The app MUST include a source display name map translating source codes to human-readable names (e.g., "XMM" -> "Monster Manual (2025)").
- **FR-003**: Search MUST operate against the full shipped index — case-insensitive substring match on creature name, minimum 2 characters, maximum 10 results, sorted alphabetically.
- **FR-004**: Search results MUST display the source display name alongside the creature name.
- **FR-005**: Adding a creature from search MUST populate name, HP, AC, and initiative data directly from the index without any network fetch.
- **FR-006**: The system MUST auto-number duplicate creature names (e.g., "Goblin 1", "Goblin 2") when multiple combatants share the same bestiary creature name. When a second copy is added, the existing combatant is renamed to include the suffix.
- **FR-007**: Auto-numbered names MUST remain editable via the existing rename functionality.
- **FR-008**: Combatants added from the bestiary MUST retain a link (`creatureId`) to their creature data so the stat block can be re-opened from the tracker.
- **FR-009**: The search field placeholder MUST display action-oriented hint text (e.g., "Search creatures to add...").
- **FR-010**: Clicking a bestiary dropdown entry MUST show a count badge (starting at 1) and a confirm button on that row.
- **FR-011**: Clicking the same dropdown entry again MUST increment the count by 1.
- **FR-012**: Only one creature type MAY be queued at a time; selecting a different creature MUST replace the current queue.
- **FR-013**: Confirming the queue (via confirm button or Enter key) MUST add N copies of the selected creature to combat and reset the queue state.
- **FR-014**: When no bestiary match exists for the typed name, the system MUST show optional input fields for initiative, AC, and max HP, each with a visible label.
- **FR-015**: Custom creatures MUST be addable with or without the optional fields filled in; invalid numeric input MUST be treated as empty.
### Acceptance Scenarios
1. **Given** the app is loaded, **When** the DM types "gob" in the search field, **Then** results include goblins from multiple sources, each labeled with the source display name, sorted alphabetically, limited to 10 results.
2. **Given** search results are visible, **When** the DM selects "Goblin (Monster Manual (2025))", **Then** a combatant is added with the correct name, HP, AC, and initiative modifier — no network request is made.
3. **Given** the app is loaded, **When** the DM types a single character, **Then** no results appear (minimum 2 characters required).
4. **Given** search results are showing, **When** the user types a query with no matches (e.g., "zzzzz"), **Then** the dropdown shows a "No creatures found" message.
5. **Given** a combatant named "Goblin" already exists, **When** the user adds another Goblin from the bestiary, **Then** the existing combatant is renamed to "Goblin 1" and the new combatant is named "Goblin 2".
6. **Given** an auto-numbered combatant "Goblin 2" exists, **When** the user edits its name, **Then** the name updates as usual (renaming is not blocked by auto-numbering).
7. **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.
8. **Given** a creature entry shows a count of N, **When** the user clicks that same entry again, **Then** the count increments to N+1.
9. **Given** a creature is queued with count N, **When** the user clicks the confirm button or presses Enter, **Then** N copies of that creature are added to combat and the queue resets.
10. **Given** the user types a name with no bestiary match, **When** the dropdown shows no results, **Then** optional input fields for initiative, AC, and max HP appear with visible labels.
11. **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).
12. **Given** the search input is open, **When** the user presses Escape, **Then** the search closes without adding a combatant.
### Edge Cases
- Two creatures from different sources sharing the same name: the source tag is shown alongside the name in search results.
- Queued creature removed from results when search query changes: the queue resets when the queued creature is no longer visible in the results.
- User presses Escape with a queued creature: the queue resets and the dropdown closes.
- Non-numeric input in optional custom creature fields: treated as empty (ignored).
---
## Stat Block Display
### User Stories
**US-D1 — View Full Stat Block in Side Panel (P2)**
As a DM, I want to see the full stat block of a creature displayed in a side panel so that I can reference its abilities, actions, and traits during combat without switching to another tool.
When a creature is selected from search results or when clicking a bestiary-linked combatant in the tracker, a stat block panel appears showing the creature's full information in the classic D&D stat block layout. Clicking a different combatant updates the panel to that creature's data.
**US-D2 — Preview Stat Block from Search Dropdown (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.
A view button in the search bar (repurposed from the current search icon) opens the stat block panel for the currently focused/highlighted creature in the dropdown without committing to adding it.
**US-D3 — Responsive Layout (P4)**
As a DM using the app on different devices, I want the layout to adapt between side-by-side (desktop) and drawer (mobile) so that the stat block is usable regardless of screen size.
### Requirements
- **FR-016**: The system MUST display a stat block panel with full creature information when a creature is selected.
- **FR-017**: The stat block MUST include: name, size, type, alignment, AC (with armor source if applicable), HP (average + formula), speed, ability scores with modifiers, saving throws, skills, damage vulnerabilities, damage resistances, damage immunities, condition immunities, senses, languages, challenge rating, proficiency bonus, passive perception, traits, actions, bonus actions, reactions, spellcasting, and legendary actions.
- **FR-018**: Optional stat block sections (traits, legendary actions, bonus actions, reactions, etc.) MUST be omitted entirely when the creature has none.
- **FR-019**: The system MUST strip bestiary markup tags (spell references, dice notation, attack tags) and render them as plain readable text (e.g., `{@spell fireball|XPHB}` -> "fireball", `{@dice 3d6}` -> "3d6").
- **FR-020**: On wide viewports (desktop), the layout MUST be side-by-side with the encounter tracker on the left and stat block on the right.
- **FR-021**: On narrow viewports (mobile), the stat block MUST appear as a dismissible drawer or slide-over.
- **FR-022**: The stat block panel MUST scroll independently of the encounter tracker.
- **FR-023**: When the user clicks a different bestiary-linked combatant in the tracker, the stat block panel MUST update to show that creature's data.
- **FR-024**: The existing search/view button MUST be repurposed to open the stat block for the currently focused/selected creature in the dropdown.
### Acceptance Scenarios
1. **Given** a creature is selected from the bestiary search, **When** the stat block panel opens, **Then** it displays: name, size, type, alignment, AC, HP (average and formula), speed, ability scores with modifiers, saving throws, skills, damage resistances/immunities, condition immunities, senses, languages, challenge rating, traits, actions, and legendary actions (if applicable).
2. **Given** the stat block panel is open on desktop (wide viewport), **Then** the layout is side-by-side: encounter tracker on the left, stat block panel on the right.
3. **Given** the stat block panel is open on mobile (narrow viewport), **Then** the stat block appears as a slide-over drawer that can be dismissed.
4. **Given** a stat block is displayed, **When** the user clicks a different bestiary-linked combatant in the tracker, **Then** the stat block panel updates to show that creature's data.
5. **Given** a creature entry contains markup tags (e.g., spell references, dice notation), **Then** they render as plain text.
6. **Given** the dropdown is showing bestiary results, **When** the user clicks the stat block view button, **Then** the stat block panel opens for the currently focused/highlighted creature in the dropdown.
7. **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).
### Edge Cases
- Creatures with no traits or legendary actions: those sections are omitted from the stat block display.
- Very long content (e.g., a Lich with extensive spellcasting): the stat block panel scrolls independently of the encounter tracker.
- Viewport resized from wide to narrow while stat block is open: the layout transitions from panel to drawer.
---
## Source Management
### User Stories
**US-M1 — View Full Stat Block via On-Demand Source Fetch (P2)**
A DM clicks to view the stat block of a creature whose source data has not been loaded yet. The app displays a prompt: "Load [Source Display Name] bestiary data?" with a pre-filled URL pointing to the raw source file. The DM confirms, the app fetches the JSON, normalizes it, and caches all creatures from that source in IndexedDB. For any subsequent creature from the same source, the stat block appears instantly without prompting.
**US-M2 — Manual File Upload as Fetch Alternative (P3)**
A DM who cannot access the URL (corporate firewall, offline use) uses a file upload option to load bestiary data from a local JSON file. The file is processed identically to a fetched file — normalized and cached by source.
**US-M3 — Bulk Load All Sources (P1)**
The user wants to pre-load all bestiary sources at once so that every creature's stat block is instantly available without per-source fetch prompts during gameplay. An import button in the top bar opens the stat block side panel with a bulk import prompt, showing the dynamic source count, an editable pre-filled base URL, and a "Load All" button. All source files are fetched concurrently; already-cached sources are skipped.
**US-M4 — Progress Feedback During Bulk Import (P1)**
While the bulk import is in progress, the user sees a text counter ("Loading sources... 34/102") and a progress bar in the side panel, giving them confidence the operation is proceeding.
**US-M5 — Toast Notification on Panel Close During Import (P2)**
If the user closes the side panel while a bulk import is still in progress, a persistent toast notification appears at the bottom-center of the screen showing the same progress text and progress bar.
**US-M6 — Manage Cached Sources (P4)**
A DM wants to see which sources are cached, clear a specific source's cache, or clear all cached data. A management UI provides this visibility and control.
### Requirements
- **FR-025**: When a user views a stat block for a creature whose source is not cached, the app MUST display a prompt to load the source data.
- **FR-026**: The source fetch prompt MUST include an editable URL field pre-filled with the default URL for that source's raw data file.
- **FR-027**: The source fetch prompt MUST appear once per source, not once per creature. After fetching a source, all its creatures' stat blocks become available.
- **FR-028**: On confirmation, the app MUST fetch the JSON, normalize it through the existing normalization pipeline, and cache all creatures from that source in IndexedDB.
- **FR-029**: Cached source data MUST persist across browser sessions using IndexedDB.
- **FR-030**: The app MUST provide a file upload option as an alternative to URL fetching, processing the uploaded file identically to a fetch.
- **FR-031**: If a fetch or upload fails, the app MUST show a user-friendly error message with options to retry or change the URL. The creature's index data (HP, AC, etc.) MUST remain intact in the encounter.
- **FR-032**: If the fetched JSON does not match the expected format, an error is shown to the user.
- **FR-033**: If persistent client-side storage is unavailable (private browsing, storage full), the app MUST fall back to in-memory caching for the current session and warn the user that data will not persist.
- **FR-034**: An import button (Lucide Import icon) in the top bar MUST open the stat block side panel with the bulk import prompt.
- **FR-035**: The bulk import prompt MUST show a descriptive text explaining the operation, including approximate data volume (~12.5 MB) and the dynamic number of sources derived from the bestiary index at runtime.
- **FR-036**: The system MUST pre-fill a base URL that the user can edit.
- **FR-037**: The system MUST construct individual fetch URLs by appending `bestiary-{sourceCode}.json` to the base URL for each source.
- **FR-038**: All fetch requests during bulk import MUST fire concurrently (browser handles connection pooling).
- **FR-039**: Already-cached sources MUST be skipped during bulk import.
- **FR-040**: The system MUST show a text counter ("Loading sources... N/T") and progress bar during bulk import.
- **FR-041**: When the user closes the side panel during an active bulk import, a toast notification MUST appear at the bottom-center of the screen showing the progress counter and progress bar.
- **FR-042**: On full success, the toast MUST auto-dismiss after a few seconds. On partial failure, the toast MUST remain visible until manually dismissed.
- **FR-043**: The toast system MUST be a lightweight custom component — no third-party toast library.
- **FR-044**: The bulk import MUST run asynchronously and not block the rest of the app.
- **FR-045**: The user MUST explicitly provide/confirm the URL before any fetches occur — the app never auto-fetches content.
- **FR-046**: The "Load All" button MUST be disabled when the URL field is empty or while a bulk import is already in progress.
- **FR-047**: The app MUST provide a management UI showing cached sources with options to clear individual sources or all cached data.
- **FR-048**: The normalization adapter and tag-stripping utility MUST remain the canonical pipeline for all fetched and uploaded data.
- **FR-049**: The distributed app bundle MUST contain zero copyrighted prose content — only mechanical facts and creature names in the search index.
### Acceptance Scenarios
1. **Given** a creature from an uncached source is in the encounter, **When** the DM opens its stat block, **Then** a prompt appears asking to load the source data with an editable URL field pre-filled with the correct raw file URL.
2. **Given** the fetch prompt is visible, **When** the DM confirms the fetch, **Then** the app downloads the JSON, normalizes it, caches all creatures from that source, and displays the stat block.
3. **Given** source data for a source has been cached, **When** the DM opens the stat block for any other creature from that source, **Then** the stat block displays instantly with no prompt.
4. **Given** the fetch prompt is visible, **When** the DM edits the URL to point to a mirror or local server, **Then** the app fetches from the edited URL instead.
5. **Given** a creature is in the encounter, **When** the DM opens its stat block and the source is not cached, **Then** the creature's index data (HP, AC, etc.) remains visible in the combatant row regardless of fetch outcome.
6. **Given** the source fetch prompt is visible, **When** the DM chooses "Upload file" and selects a valid bestiary JSON, **Then** the app normalizes and caches the data, and stat blocks become available.
7. **Given** the DM uploads an invalid or malformed JSON file, **When** the upload completes, **Then** the app shows a user-friendly error message and allows retry.
8. **Given** no sources are cached, **When** the user clicks the import button in the top bar, **Then** the stat block side panel opens showing a descriptive explanation, an editable pre-filled base URL, and a "Load All" button.
9. **Given** the bulk import prompt is visible, **When** the user clicks "Load All", **Then** the app fires fetch requests for all sources concurrently, normalizes each response, and caches results in IndexedDB.
10. **Given** some sources are already cached, **When** the user initiates a bulk import, **Then** already-cached sources are skipped and only uncached sources are fetched.
11. **Given** a bulk import is in progress, **When** the user views the side panel, **Then** they see a text counter (e.g., "Loading sources... 34/102") and a visual progress bar.
12. **Given** each source finishes loading, **Then** the counter and progress bar update immediately.
13. **Given** a bulk import is in progress, **When** the user closes the side panel, **Then** a toast notification appears at the bottom-center showing the progress counter and progress bar.
14. **Given** the toast is visible and all sources finish loading successfully, **Then** the toast shows "All sources loaded" and auto-dismisses after a few seconds.
15. **Given** the toast is visible and some sources fail, **Then** the toast shows "Loaded N/T sources (F failed)" and remains visible until the user dismisses it.
16. **Given** two sources have been cached, **When** the DM opens the source management UI, **Then** both sources are listed with their display names.
17. **Given** the source management UI is open, **When** the DM clears a single source, **Then** that source's data is removed; stat blocks for its creatures require re-fetching, while other cached sources remain.
18. **Given** the source management UI is open, **When** the DM clears all cached data, **Then** all source data is removed and all stat blocks require re-fetching.
### Edge Cases
- Network fetch fails mid-download: the app shows an error with the option to retry or change the URL; the creature remains in the encounter with its index data intact.
- Fetched JSON does not match expected format: normalization failure shows an error to the user.
- User adds a creature, caches its source, then clears the cache: the creature remains in the encounter with its index data; opening the stat block triggers the fetch prompt again.
- Storage unavailable (private browsing, storage full): fall back to in-memory caching for the current session with a warning.
- Browser is offline: the fetch prompt is shown but the fetch fails; the DM can use the file upload alternative; previously cached sources remain available.
- "Load All" clicked while a bulk import is already in progress: button is disabled during an active import.
- All sources already cached before bulk import: the operation completes immediately and reports "All sources loaded".
- Network completely unavailable during bulk import: all fetches fail; result shows "Loaded 0/T sources (T failed)".
- User navigates away or refreshes during import: partially completed caches persist; the user can re-run to pick up remaining sources.
- Base URL is empty or invalid: the "Load All" button is disabled.
---
## Panel UX (Fold, Pin, Second Panel)
### User Stories
**US-P1 — Fold and Unfold Stat Block Panel (P1)**
As a DM running an encounter, I want to collapse the stat block panel to a slim tab so I can temporarily reclaim screen space without losing my place, then quickly expand it again to reference creature stats.
The close button is replaced with a fold/unfold toggle. Folding slides the panel out to the right edge, leaving a slim vertical tab displaying the creature's name. Clicking the tab unfolds the panel, showing the same creature that was displayed before folding. No "Stat Block" heading text is shown in the panel header.
**US-P2 — Pin Creature to Second Panel (P2)**
As a DM comparing creatures or referencing one creature while browsing others, I want to pin the current creature to a secondary panel on the left side of the screen so I can keep it visible while browsing different creatures in the right panel.
Clicking the pin button copies the current creature to a new left panel. The right panel remains active for browsing different creatures independently. The left panel has an unpin button that removes it.
**US-P3 — Fold Behavior with Pinned Panel (P3)**
As a DM with a creature pinned, I want to fold the right (browse) panel independently so I can focus on just the pinned creature, or fold both panels to see the full encounter list.
### Requirements
- **FR-050**: The system MUST replace the close button on the stat block panel with a fold/unfold toggle control.
- **FR-051**: The system MUST remove the "Stat Block" heading text from the panel header.
- **FR-052**: When folded, the panel MUST collapse to a slim vertical tab anchored to the right edge of the screen displaying the creature's name.
- **FR-053**: Folding and unfolding MUST use a smooth CSS slide animation (~200ms ease-out).
- **FR-054**: The fold/unfold toggle MUST preserve the currently displayed creature — unfolding shows the same creature that was visible when folded.
- **FR-055**: The panel MUST include a pin button that copies the current creature to a new panel on the left side of the screen.
- **FR-056**: After pinning, the right panel MUST remain active for browsing different creatures independently.
- **FR-057**: The pinned (left) panel MUST include an unpin button that removes it when clicked.
- **FR-058**: The pin button MUST be hidden on viewports where dual panels would not fit (small screens / mobile).
- **FR-059**: The pin button MUST be hidden when the panel is showing a source fetch prompt (no creature data displayed yet).
- **FR-060**: On mobile viewports, the existing drawer/backdrop behavior MUST be preserved — fold/unfold replaces only the close button behavior for the desktop layout; the backdrop click still dismisses the panel.
- **FR-061**: Both the browse (right) and pinned (left) panels MUST have independent fold states.
### Acceptance Scenarios
1. **Given** the stat block panel is open showing a creature, **When** the user clicks the fold button, **Then** the panel slides out to the right edge and a slim vertical tab appears showing the creature's name.
2. **Given** the stat block panel is folded to a tab, **When** the user clicks the tab, **Then** the panel slides back in showing the same creature that was displayed before folding.
3. **Given** the stat block panel is open, **When** the user looks for a close button, **Then** no close button is present — only a fold toggle.
4. **Given** the stat block panel is open, **When** the user looks at the panel header, **Then** no "Stat Block" heading text is visible.
5. **Given** the panel is folding or unfolding, **When** the animation plays, **Then** it completes with a smooth slide transition (~200ms ease-out).
6. **Given** the stat block panel is showing a creature on a wide screen, **When** the user clicks the pin button, **Then** the current creature appears in a new panel on the left side of the screen.
7. **Given** a creature is pinned to the left panel, **When** the user clicks a different combatant in the encounter list, **Then** the right panel updates to show the new creature while the left panel continues showing the pinned creature.
8. **Given** a creature is pinned to the left panel, **When** the user clicks the unpin button on the left panel, **Then** the left panel is removed and only the right panel remains.
9. **Given** the user is on a small screen or mobile viewport, **When** the stat block panel is displayed, **Then** the pin button is not visible.
10. **Given** both pinned (left) and browse (right) panels are open, **When** the user folds the right panel, **Then** the left pinned panel remains visible and the right panel collapses to a tab.
11. **Given** the right panel is folded and the left panel is pinned, **When** the user unfolds the right panel, **Then** it slides back showing the last browsed creature.
### Edge Cases
- User pins a creature and then that creature is removed from the encounter: the pinned panel continues displaying the creature's stat block (data is already loaded).
- User folds the panel and then the active combatant changes (auto-show logic on desktop): the panel stays folded but updates the selected creature internally; unfolding shows the current active combatant's stat block. The fold state is respected — advancing turns does not override a user-chosen fold.
- Viewport resized from wide to narrow while a creature is pinned: the pinned (left) panel is hidden and the pin button disappears; resizing back to wide restores the pinned panel.
- User is in bulk import mode and tries to fold: the fold/unfold behavior applies to the bulk import panel identically.
- Panel showing a source fetch prompt: the pin button is hidden.
---
## Key Entities
- **Search Index** (`BestiaryIndex`): Pre-shipped lightweight dataset keyed by name + source, containing mechanical facts (name, source code, AC, HP average, DEX score, CR, initiative proficiency multiplier, size, type) for all creatures. Sufficient for adding combatants; insufficient for rendering a full stat block.
- **Source** (`BestiarySource`): A D&D publication identified by a code (e.g., "XMM") with a display name (e.g., "Monster Manual (2025)"). Caching and fetching operate at the source level.
- **Creature (Full)** (`Creature`): A complete creature record with all stat block data (traits, actions, legendary actions, spellcasting, etc.), available only after source data is fetched/uploaded and cached. Identified by a branded `CreatureId`.
- **Cached Source Data**: The full normalized bestiary data for a single source, stored in IndexedDB. Contains complete creature stat blocks.
- **Combatant** (extended): Gains an optional `creatureId` reference to a `Creature`, enabling stat block lookup and stat pre-fill on creation.
- **Queued Creature**: Transient UI-only state representing a bestiary creature selected for batch-add, containing the creature reference and a count (1+). Not persisted.
- **Bulk Import Operation**: Tracks total sources, completed count, failed count, and current status (idle / loading / complete / partial-failure).
- **Toast Notification**: Lightweight custom UI element at bottom-center of screen with text, optional progress bar, and optional dismiss button.
- **Panel State**: Represents whether a stat block panel is expanded, folded, or absent. The browse (right) and pinned (left) panels each have independent state.
---
## Success Criteria *(mandatory)*
- **SC-001**: All 3,312+ indexed creatures are searchable immediately on app load, with search results appearing within 100ms of typing.
- **SC-002**: Adding a creature from search to the encounter completes without any network request and within 200ms.
- **SC-003**: After a source is cached, stat blocks for any creature from that source display within 200ms with no additional prompt.
- **SC-004**: The distributed app bundle contains zero copyrighted prose content — only mechanical facts and creature names in the search index.
- **SC-005**: Source data import (fetch or upload) for a typical source completes and becomes usable within 5 seconds on a standard broadband connection.
- **SC-006**: Cached data persists across browser sessions — closing and reopening the browser does not require re-fetching previously loaded sources.
- **SC-007**: The app bundle size is smaller than a bundled-full-bestiary approach, shipping only the lightweight index.
- **SC-008**: 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-009**: All stat block sections render correctly for all creatures (no missing data, no raw markup tags visible).
- **SC-010**: The stat block panel is readable and fully functional on viewports from 375px (mobile) to 2560px (ultrawide) without horizontal scrolling or content clipping.
- **SC-011**: Users can load all bestiary sources with a single confirmation action; real-time progress is visible during the operation.
- **SC-012**: Already-cached sources are skipped during bulk import, reducing redundant data transfer on repeat imports.
- **SC-013**: The rest of the app remains fully interactive during the bulk import operation.
- **SC-014**: Users can fold the stat block panel in a single click and unfold it in a single click, with the transition completing in under 300ms.
- **SC-015**: Users can pin a creature and browse a different creature's stat block simultaneously, without any additional navigation steps.
- **SC-016**: The pinned panel is never visible on screens narrower than the dual-panel breakpoint, ensuring mobile usability is not degraded.
- **SC-017**: All fold/unfold and pin/unpin interactions are discoverable through visible button controls without requiring documentation or tooltips.