From 86768842ff9d53d99e504de0c2e9036c2c39a624 Mon Sep 17 00:00:00 2001 From: Lukas Date: Thu, 19 Mar 2026 14:19:58 +0100 Subject: [PATCH] Refactor App.tsx from god component to context-based architecture Co-Authored-By: Claude Opus 4.6 (1M context) --- .specify/memory/constitution.md | 22 +- CLAUDE.md | 2 + apps/web/src/App.tsx | 376 ++---------------- .../src/__tests__/app-integration.test.tsx | 9 +- .../stat-block-collapse-pin.test.tsx | 161 +++++--- apps/web/src/__tests__/test-providers.tsx | 28 ++ .../components/__tests__/action-bar.test.tsx | 99 +++-- .../__tests__/combatant-row.test.tsx | 98 +++-- .../__tests__/source-manager.test.tsx | 43 +- .../__tests__/turn-navigation.test.tsx | 124 +++--- apps/web/src/components/action-bar.tsx | 116 +++--- .../web/src/components/bulk-import-prompt.tsx | 34 +- .../web/src/components/bulk-import-toasts.tsx | 21 +- apps/web/src/components/combatant-row.tsx | 96 ++--- .../components/player-character-section.tsx | 40 +- .../src/components/source-fetch-prompt.tsx | 16 +- apps/web/src/components/source-manager.tsx | 14 +- apps/web/src/components/stat-block-panel.tsx | 105 +++-- apps/web/src/components/turn-navigation.tsx | 27 +- apps/web/src/contexts/bestiary-context.tsx | 23 ++ apps/web/src/contexts/bulk-import-context.tsx | 21 + apps/web/src/contexts/encounter-context.tsx | 21 + apps/web/src/contexts/index.ts | 7 + .../src/contexts/initiative-rolls-context.tsx | 25 ++ .../contexts/player-characters-context.tsx | 29 ++ apps/web/src/contexts/side-panel-context.tsx | 21 + apps/web/src/contexts/theme-context.tsx | 19 + .../web/src/hooks/use-action-bar-animation.ts | 38 ++ apps/web/src/hooks/use-auto-stat-block.ts | 17 + apps/web/src/hooks/use-bulk-import.ts | 2 +- apps/web/src/hooks/use-initiative-rolls.ts | 75 ++++ apps/web/src/main.tsx | 27 +- knip.json | 2 +- package.json | 3 +- scripts/check-component-props.mjs | 99 +++++ 35 files changed, 1065 insertions(+), 795 deletions(-) create mode 100644 apps/web/src/__tests__/test-providers.tsx create mode 100644 apps/web/src/contexts/bestiary-context.tsx create mode 100644 apps/web/src/contexts/bulk-import-context.tsx create mode 100644 apps/web/src/contexts/encounter-context.tsx create mode 100644 apps/web/src/contexts/index.ts create mode 100644 apps/web/src/contexts/initiative-rolls-context.tsx create mode 100644 apps/web/src/contexts/player-characters-context.tsx create mode 100644 apps/web/src/contexts/side-panel-context.tsx create mode 100644 apps/web/src/contexts/theme-context.tsx create mode 100644 apps/web/src/hooks/use-action-bar-animation.ts create mode 100644 apps/web/src/hooks/use-auto-stat-block.ts create mode 100644 apps/web/src/hooks/use-initiative-rolls.ts create mode 100644 scripts/check-component-props.mjs diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index 1b52dca..dd57eab 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,9 +1,9 @@ # Encounter Console Constitution @@ -38,6 +38,22 @@ dependency direction: A module in an inner layer MUST NOT import from an outer layer. +### II-A. Context-Based State Flow + +UI components MUST consume shared application state via React context +providers, not prop drilling. Props are reserved for per-instance +configuration (e.g., a specific data item, a layout variant, a ref). + +- Components MUST NOT declare more than 8 explicit props in their + own interface. This is enforced by `scripts/check-component-props.mjs` + at pre-commit. +- Generic UI primitives (`components/ui/`) that extend HTML element + attributes are exempt — only explicitly declared props count, not + inherited HTML attributes. +- Coordinating hooks that consume multiple contexts (e.g., + `useInitiativeRolls`) are preferred over wiring callbacks through + a parent component. + ### III. Clarification-First Before making any non-trivial assumption during specification, @@ -140,4 +156,4 @@ MUST comply with its principles. **Compliance review**: Every spec and plan MUST include a Constitution Check section validating adherence to all principles. -**Version**: 3.0.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-11 +**Version**: 3.1.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-19 diff --git a/CLAUDE.md b/CLAUDE.md index 699b6c6..db64541 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,7 @@ pnpm test:watch # Tests in watch mode pnpm typecheck # tsc --build (project references) pnpm lint # Biome lint pnpm format # Biome format (writes) +pnpm check:props # Component prop count enforcement (max 8) pnpm --filter web dev # Vite dev server (localhost:5173) pnpm --filter web build # Production build ``` @@ -71,6 +72,7 @@ docs/agents/ RPI skill artifacts (research reports, plans) - **Domain events** are plain data objects with a `type` discriminant — no classes. - **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks. - **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`. +- **Component props** — max 8 explicitly declared props per component interface (enforced by `scripts/check-component-props.mjs`). Use React context for shared state; reserve props for per-instance config (data items, layout variants, refs). - **Quality gates** are enforced at pre-commit via Lefthook's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process. ## Self-Review Checklist diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index d7f2593..6c953ec 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,239 +1,43 @@ -import { - rollAllInitiativeUseCase, - rollInitiativeUseCase, -} from "@initiative/application"; -import { - type CombatantId, - type Creature, - type CreatureId, - isDomainError, - type RollMode, -} from "@initiative/domain"; -import { - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, -} from "react"; -import { ActionBar } from "./components/action-bar"; -import { BulkImportToasts } from "./components/bulk-import-toasts"; -import { CombatantRow } from "./components/combatant-row"; +import { useEffect, useRef } from "react"; +import { ActionBar } from "./components/action-bar.js"; +import { BulkImportToasts } from "./components/bulk-import-toasts.js"; +import { CombatantRow } from "./components/combatant-row.js"; import { PlayerCharacterSection, type PlayerCharacterSectionHandle, -} from "./components/player-character-section"; -import { StatBlockPanel } from "./components/stat-block-panel"; -import { Toast } from "./components/toast"; -import { TurnNavigation } from "./components/turn-navigation"; -import { type SearchResult, useBestiary } from "./hooks/use-bestiary"; -import { useBulkImport } from "./hooks/use-bulk-import"; -import { useEncounter } from "./hooks/use-encounter"; -import { usePlayerCharacters } from "./hooks/use-player-characters"; -import { useSidePanelState } from "./hooks/use-side-panel-state"; -import { useTheme } from "./hooks/use-theme"; -import { cn } from "./lib/utils"; - -function rollDice(): number { - return Math.floor(Math.random() * 20) + 1; -} - -function useActionBarAnimation(combatantCount: number) { - const wasEmptyRef = useRef(combatantCount === 0); - const [settling, setSettling] = useState(false); - const [rising, setRising] = useState(false); - const [topBarExiting, setTopBarExiting] = useState(false); - - useLayoutEffect(() => { - const nowEmpty = combatantCount === 0; - if (wasEmptyRef.current && !nowEmpty) { - setSettling(true); - } else if (!wasEmptyRef.current && nowEmpty) { - setRising(true); - setTopBarExiting(true); - } - wasEmptyRef.current = nowEmpty; - }, [combatantCount]); - - const empty = combatantCount === 0; - const risingClass = rising ? "animate-rise-to-center" : ""; - const settlingClass = settling ? "animate-settle-to-bottom" : ""; - const exitingClass = topBarExiting - ? "absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out" - : ""; - const topBarClass = settling ? "animate-slide-down-in" : exitingClass; - const showTopBar = !empty || topBarExiting; - - return { - risingClass, - settlingClass, - topBarClass, - showTopBar, - onSettleEnd: () => setSettling(false), - onRiseEnd: () => setRising(false), - onTopBarExitEnd: () => setTopBarExiting(false), - }; -} +} from "./components/player-character-section.js"; +import { StatBlockPanel } from "./components/stat-block-panel.js"; +import { Toast } from "./components/toast.js"; +import { TurnNavigation } from "./components/turn-navigation.js"; +import { useEncounterContext } from "./contexts/encounter-context.js"; +import { useInitiativeRollsContext } from "./contexts/initiative-rolls-context.js"; +import { useSidePanelContext } from "./contexts/side-panel-context.js"; +import { useActionBarAnimation } from "./hooks/use-action-bar-animation.js"; +import { useAutoStatBlock } from "./hooks/use-auto-stat-block.js"; +import { cn } from "./lib/utils.js"; export function App() { - const { - encounter, - isEmpty, - hasCreatureCombatants, - canRollAllInitiative, - advanceTurn, - retreatTurn, - addCombatant, - clearEncounter, - removeCombatant, - editCombatant, - setInitiative, - setHp, - adjustHp, - setAc, - toggleCondition, - toggleConcentration, - addFromBestiary, - addFromPlayerCharacter, - makeStore, - } = useEncounter(); + const { encounter, isEmpty } = useEncounterContext(); + const sidePanel = useSidePanelContext(); + const rolls = useInitiativeRollsContext(); - const { - characters: playerCharacters, - createCharacter: createPlayerCharacter, - editCharacter: editPlayerCharacter, - deleteCharacter: deletePlayerCharacter, - } = usePlayerCharacters(); + useAutoStatBlock(); - const { - search, - getCreature, - isLoaded, - isSourceCached, - fetchAndCacheSource, - uploadAndCacheSource, - refreshCache, - } = useBestiary(); - - const bulkImport = useBulkImport(); - const sidePanel = useSidePanelState(); - const { preference: themePreference, cycleTheme } = useTheme(); - - const [rollSkippedCount, setRollSkippedCount] = useState(0); - const [rollSingleSkipped, setRollSingleSkipped] = useState(false); - - const selectedCreature: Creature | null = sidePanel.selectedCreatureId - ? (getCreature(sidePanel.selectedCreatureId) ?? null) - : null; - - const pinnedCreature: Creature | null = sidePanel.pinnedCreatureId - ? (getCreature(sidePanel.pinnedCreatureId) ?? null) - : null; - - const handleAddFromBestiary = useCallback( - (result: SearchResult) => { - const creatureId = addFromBestiary(result); - if (creatureId && sidePanel.panelView.mode === "closed") { - sidePanel.showCreature(creatureId); - } - }, - [addFromBestiary, sidePanel.panelView.mode, sidePanel.showCreature], - ); - - const handleCombatantStatBlock = useCallback( - (creatureId: string) => { - sidePanel.showCreature(creatureId as CreatureId); - }, - [sidePanel.showCreature], - ); - - const handleRollInitiative = useCallback( - (id: CombatantId, mode: RollMode = "normal") => { - const diceRolls: [number, ...number[]] = - mode === "normal" ? [rollDice()] : [rollDice(), rollDice()]; - const result = rollInitiativeUseCase( - makeStore(), - id, - diceRolls, - getCreature, - mode, - ); - if (isDomainError(result)) { - setRollSingleSkipped(true); - const combatant = encounter.combatants.find((c) => c.id === id); - if (combatant?.creatureId) { - sidePanel.showCreature(combatant.creatureId); - } - } - }, - [makeStore, getCreature, encounter.combatants, sidePanel.showCreature], - ); - - const handleRollAllInitiative = useCallback( - (mode: RollMode = "normal") => { - const result = rollAllInitiativeUseCase( - makeStore(), - rollDice, - getCreature, - mode, - ); - if (!isDomainError(result) && result.skippedNoSource > 0) { - setRollSkippedCount(result.skippedNoSource); - } - }, - [makeStore, getCreature], - ); - - const handleViewStatBlock = useCallback( - (result: SearchResult) => { - const slug = result.name - .toLowerCase() - .replaceAll(/[^a-z0-9]+/g, "-") - .replaceAll(/(^-|-$)/g, ""); - const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId; - sidePanel.showCreature(cId); - }, - [sidePanel.showCreature], - ); - - const handleStartBulkImport = useCallback( - (baseUrl: string) => { - bulkImport.startImport( - baseUrl, - fetchAndCacheSource, - isSourceCached, - refreshCache, - ); - }, - [bulkImport.startImport, fetchAndCacheSource, isSourceCached, refreshCache], - ); - - const handleBulkImportDone = useCallback(() => { - sidePanel.dismissPanel(); - bulkImport.reset(); - }, [sidePanel.dismissPanel, bulkImport.reset]); - - const actionBarInputRef = useRef(null); const playerCharacterRef = useRef(null); + const actionBarInputRef = useRef(null); + const activeRowRef = useRef(null); const actionBarAnim = useActionBarAnimation(encounter.combatants.length); - // Auto-update stat block panel when the active combatant changes - const activeCreatureId = - encounter.combatants[encounter.activeIndex]?.creatureId; + // Auto-scroll to active combatant when turn changes + const activeIndex = encounter.activeIndex; useEffect(() => { - if (activeCreatureId && sidePanel.panelView.mode === "creature") { - sidePanel.updateCreature(activeCreatureId); + if (activeIndex >= 0) { + activeRowRef.current?.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }); } - }, [activeCreatureId, sidePanel.panelView.mode, sidePanel.updateCreature]); - - // Auto-scroll to the active combatant when the turn changes - const activeRowRef = useRef(null); - useEffect(() => { - activeRowRef.current?.scrollIntoView({ - block: "nearest", - behavior: "smooth", - }); - }, []); + }, [activeIndex]); return (
@@ -243,49 +47,27 @@ export function App() { className={cn("shrink-0 pt-8", actionBarAnim.topBarClass)} onAnimationEnd={actionBarAnim.onTopBarExitEnd} > - +
)} {isEmpty ? ( - /* Empty state — ActionBar centered */
playerCharacterRef.current?.openManagement() } - onRollAllInitiative={handleRollAllInitiative} - showRollAllInitiative={hasCreatureCombatants} - rollAllInitiativeDisabled={!canRollAllInitiative} - onOpenSourceManager={sidePanel.showSourceManager} - themePreference={themePreference} - onCycleTheme={cycleTheme} autoFocus />
) : ( <> - {/* Scrollable area — combatant list */}
{encounter.combatants.map((c, i) => ( @@ -294,133 +76,51 @@ export function App() { ref={i === encounter.activeIndex ? activeRowRef : null} combatant={c} isActive={i === encounter.activeIndex} - onRename={editCombatant} - onSetInitiative={setInitiative} - onRemove={removeCombatant} - onSetHp={setHp} - onAdjustHp={adjustHp} - onSetAc={setAc} - onToggleCondition={toggleCondition} - onToggleConcentration={toggleConcentration} - onShowStatBlock={ - c.creatureId - ? () => handleCombatantStatBlock(c.creatureId as string) - : undefined - } - isStatBlockOpen={ - c.creatureId === sidePanel.selectedCreatureId - } - onRollInitiative={ - c.creatureId ? handleRollInitiative : undefined - } /> ))}
- {/* Action Bar — fixed at bottom */}
playerCharacterRef.current?.openManagement() } - onRollAllInitiative={handleRollAllInitiative} - showRollAllInitiative={hasCreatureCombatants} - rollAllInitiativeDisabled={!canRollAllInitiative} - onOpenSourceManager={sidePanel.showSourceManager} - themePreference={themePreference} - onCycleTheme={cycleTheme} />
)} - {/* Pinned Stat Block Panel (left) */} {!!sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && ( - {}} - onPin={() => {}} - onUnpin={sidePanel.unpin} - showPinButton={false} - side="left" - onDismiss={() => {}} - /> + )} - {/* Browse Stat Block Panel (right) */} - {}} - showPinButton={sidePanel.isWideDesktop && !!selectedCreature} - side="right" - onDismiss={sidePanel.dismissPanel} - bulkImportMode={sidePanel.bulkImportMode} - bulkImportState={bulkImport.state} - onStartBulkImport={handleStartBulkImport} - onBulkImportDone={handleBulkImportDone} - sourceManagerMode={sidePanel.sourceManagerMode} - /> + - + - {rollSkippedCount > 0 && ( + {rolls.rollSkippedCount > 0 && ( setRollSkippedCount(0)} + message={`${rolls.rollSkippedCount} skipped — bestiary source not loaded`} + onDismiss={rolls.dismissRollSkipped} autoDismissMs={4000} /> )} - {!!rollSingleSkipped && ( + {!!rolls.rollSingleSkipped && ( setRollSingleSkipped(false)} + onDismiss={rolls.dismissRollSingleSkipped} autoDismissMs={4000} /> )} - + ); } diff --git a/apps/web/src/__tests__/app-integration.test.tsx b/apps/web/src/__tests__/app-integration.test.tsx index 7832a35..97a0e29 100644 --- a/apps/web/src/__tests__/app-integration.test.tsx +++ b/apps/web/src/__tests__/app-integration.test.tsx @@ -4,7 +4,8 @@ import "@testing-library/jest-dom/vitest"; import { cleanup, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { App } from "../App"; +import { App } from "../App.js"; +import { AllProviders } from "./test-providers.js"; // Mock persistence — no localStorage interaction vi.mock("../persistence/encounter-storage.js", () => ({ @@ -76,7 +77,7 @@ async function addCombatant( describe("App integration", () => { it("adds a combatant and removes it, returning to empty state", async () => { const user = userEvent.setup(); - render(); + render(, { wrapper: AllProviders }); // Empty state: centered input visible, no TurnNavigation expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument(); @@ -109,7 +110,7 @@ describe("App integration", () => { it("advances and retreats turns across two combatants", async () => { const user = userEvent.setup(); - render(); + render(, { wrapper: AllProviders }); await addCombatant(user, "Fighter"); await addCombatant(user, "Wizard"); @@ -137,7 +138,7 @@ describe("App integration", () => { it("adds a combatant with HP, applies damage, and shows unconscious state", async () => { const user = userEvent.setup(); - render(); + render(, { wrapper: AllProviders }); await addCombatant(user, "Ogre", { maxHp: "59" }); diff --git a/apps/web/src/__tests__/stat-block-collapse-pin.test.tsx b/apps/web/src/__tests__/stat-block-collapse-pin.test.tsx index 84311c7..e35aa2f 100644 --- a/apps/web/src/__tests__/stat-block-collapse-pin.test.tsx +++ b/apps/web/src/__tests__/stat-block-collapse-pin.test.tsx @@ -4,11 +4,33 @@ import "@testing-library/jest-dom/vitest"; import type { Creature, CreatureId } from "@initiative/domain"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { StatBlockPanel } from "../components/stat-block-panel"; + +// Mock the context modules +vi.mock("../contexts/side-panel-context.js", () => ({ + useSidePanelContext: vi.fn(), +})); + +vi.mock("../contexts/bestiary-context.js", () => ({ + useBestiaryContext: vi.fn(), +})); + +// Mock adapters to avoid IndexedDB +vi.mock("../adapters/bestiary-index-adapter.js", () => ({ + loadBestiaryIndex: () => ({ sources: {}, creatures: [] }), + getAllSourceCodes: () => [], + getDefaultFetchUrl: () => "", + getSourceDisplayName: (code: string) => code, +})); + +import { StatBlockPanel } from "../components/stat-block-panel.js"; +import { useBestiaryContext } from "../contexts/bestiary-context.js"; +import { useSidePanelContext } from "../contexts/side-panel-context.js"; + +const mockUseSidePanelContext = vi.mocked(useSidePanelContext); +const mockUseBestiaryContext = vi.mocked(useBestiaryContext); const CLOSE_REGEX = /close/i; const COLLAPSE_REGEX = /collapse/i; - const CREATURE_ID = "srd:goblin" as CreatureId; const CREATURE: Creature = { id: CREATURE_ID, @@ -44,41 +66,65 @@ function mockMatchMedia(matches: boolean) { }); } -interface PanelProps { +interface PanelOverrides { creatureId?: CreatureId | null; creature?: Creature | null; panelRole?: "browse" | "pinned"; isCollapsed?: boolean; - onToggleCollapse?: () => void; - onPin?: () => void; - onUnpin?: () => void; - showPinButton?: boolean; side?: "left" | "right"; - onDismiss?: () => void; bulkImportMode?: boolean; } -function renderPanel(overrides: PanelProps = {}) { - const props = { - creatureId: CREATURE_ID, - creature: CREATURE, +function setupMocks(overrides: PanelOverrides = {}) { + const panelRole = overrides.panelRole ?? "browse"; + const creatureId = overrides.creatureId ?? CREATURE_ID; + const creature = overrides.creature ?? CREATURE; + const isCollapsed = overrides.isCollapsed ?? false; + + const onToggleCollapse = vi.fn(); + const onPin = vi.fn(); + const onUnpin = vi.fn(); + const onDismiss = vi.fn(); + + mockUseSidePanelContext.mockReturnValue({ + selectedCreatureId: panelRole === "browse" ? creatureId : null, + pinnedCreatureId: panelRole === "pinned" ? creatureId : null, + isRightPanelCollapsed: panelRole === "browse" ? isCollapsed : false, + isWideDesktop: false, + bulkImportMode: overrides.bulkImportMode ?? false, + sourceManagerMode: false, + panelView: creatureId + ? { mode: "creature" as const, creatureId } + : { mode: "closed" as const }, + showCreature: vi.fn(), + updateCreature: vi.fn(), + showBulkImport: vi.fn(), + showSourceManager: vi.fn(), + dismissPanel: onDismiss, + toggleCollapse: onToggleCollapse, + togglePin: onPin, + unpin: onUnpin, + } as ReturnType); + + mockUseBestiaryContext.mockReturnValue({ + getCreature: (id: CreatureId) => (id === creatureId ? creature : undefined), isSourceCached: vi.fn().mockResolvedValue(true), + search: vi.fn().mockReturnValue([]), + isLoaded: true, fetchAndCacheSource: vi.fn(), uploadAndCacheSource: vi.fn(), refreshCache: vi.fn(), - panelRole: "browse" as const, - isCollapsed: false, - onToggleCollapse: vi.fn(), - onPin: vi.fn(), - onUnpin: vi.fn(), - showPinButton: false, - side: "right" as const, - onDismiss: vi.fn(), - ...overrides, - }; + } as ReturnType); - render(); - return props; + return { onToggleCollapse, onPin, onUnpin, onDismiss }; +} + +function renderPanel(overrides: PanelOverrides = {}) { + const callbacks = setupMocks(overrides); + const panelRole = overrides.panelRole ?? "browse"; + const side = overrides.side ?? (panelRole === "pinned" ? "left" : "right"); + render(); + return callbacks; } describe("Stat Block Panel Collapse/Expand and Pin", () => { @@ -113,19 +159,19 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => { }); it("calls onToggleCollapse when collapse button is clicked", () => { - const props = renderPanel(); + const callbacks = renderPanel(); fireEvent.click( screen.getByRole("button", { name: "Collapse stat block panel" }), ); - expect(props.onToggleCollapse).toHaveBeenCalledTimes(1); + expect(callbacks.onToggleCollapse).toHaveBeenCalledTimes(1); }); it("calls onToggleCollapse when collapsed tab is clicked", () => { - const props = renderPanel({ isCollapsed: true }); + const callbacks = renderPanel({ isCollapsed: true }); fireEvent.click( screen.getByRole("button", { name: "Expand stat block panel" }), ); - expect(props.onToggleCollapse).toHaveBeenCalledTimes(1); + expect(callbacks.onToggleCollapse).toHaveBeenCalledTimes(1); }); it("applies translate-x class when collapsed (right side)", () => { @@ -163,53 +209,58 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => { }); it("calls onDismiss when backdrop is clicked on mobile", () => { - const props = renderPanel(); + const callbacks = renderPanel(); fireEvent.click(screen.getByRole("button", { name: "Close stat block" })); - expect(props.onDismiss).toHaveBeenCalledTimes(1); + expect(callbacks.onDismiss).toHaveBeenCalledTimes(1); }); it("does not render pinned panel on mobile", () => { const { container } = render( - , + (() => { + setupMocks({ panelRole: "pinned" }); + return ; + })(), ); expect(container.innerHTML).toBe(""); }); }); describe("US2: Pin and Unpin", () => { - it("shows pin button when showPinButton is true on desktop", () => { - renderPanel({ showPinButton: true }); + it("shows pin button when isWideDesktop is true on desktop", () => { + setupMocks(); + // Override to set isWideDesktop + const ctx = mockUseSidePanelContext.mock.results[0]?.value as ReturnType< + typeof useSidePanelContext + >; + mockUseSidePanelContext.mockReturnValue({ + ...ctx, + isWideDesktop: true, + }); + render(); expect( screen.getByRole("button", { name: "Pin creature" }), ).toBeInTheDocument(); }); - it("hides pin button when showPinButton is false", () => { - renderPanel({ showPinButton: false }); + it("hides pin button when isWideDesktop is false", () => { + renderPanel(); expect( screen.queryByRole("button", { name: "Pin creature" }), ).not.toBeInTheDocument(); }); it("calls onPin when pin button is clicked", () => { - const props = renderPanel({ showPinButton: true }); + setupMocks(); + const ctx = mockUseSidePanelContext.mock.results[0]?.value as ReturnType< + typeof useSidePanelContext + >; + mockUseSidePanelContext.mockReturnValue({ + ...ctx, + isWideDesktop: true, + }); + render(); fireEvent.click(screen.getByRole("button", { name: "Pin creature" })); - expect(props.onPin).toHaveBeenCalledTimes(1); + expect(ctx.togglePin).toHaveBeenCalledTimes(1); }); it("shows unpin button for pinned role", () => { @@ -220,9 +271,9 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => { }); it("calls onUnpin when unpin button is clicked", () => { - const props = renderPanel({ panelRole: "pinned", side: "left" }); + const callbacks = renderPanel({ panelRole: "pinned", side: "left" }); fireEvent.click(screen.getByRole("button", { name: "Unpin creature" })); - expect(props.onUnpin).toHaveBeenCalledTimes(1); + expect(callbacks.onUnpin).toHaveBeenCalledTimes(1); }); it("positions pinned panel on the left side", () => { @@ -255,7 +306,7 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => { }); it("pinned panel is always expanded (no translate offset)", () => { - renderPanel({ panelRole: "pinned", side: "left", isCollapsed: false }); + renderPanel({ panelRole: "pinned", side: "left" }); const unpinBtn = screen.getByRole("button", { name: "Unpin creature", }); diff --git a/apps/web/src/__tests__/test-providers.tsx b/apps/web/src/__tests__/test-providers.tsx new file mode 100644 index 0000000..f9fc82f --- /dev/null +++ b/apps/web/src/__tests__/test-providers.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from "react"; +import { + BestiaryProvider, + BulkImportProvider, + EncounterProvider, + InitiativeRollsProvider, + PlayerCharactersProvider, + SidePanelProvider, + ThemeProvider, +} from "../contexts/index.js"; + +export function AllProviders({ children }: { children: ReactNode }) { + return ( + + + + + + + {children} + + + + + + + ); +} diff --git a/apps/web/src/components/__tests__/action-bar.test.tsx b/apps/web/src/components/__tests__/action-bar.test.tsx index 4ef5806..bbb7e6b 100644 --- a/apps/web/src/components/__tests__/action-bar.test.tsx +++ b/apps/web/src/components/__tests__/action-bar.test.tsx @@ -3,21 +3,59 @@ import "@testing-library/jest-dom/vitest"; import { cleanup, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { ActionBar } from "../action-bar"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { AllProviders } from "../../__tests__/test-providers.js"; +import { ActionBar } from "../action-bar.js"; + +// Mock persistence — no localStorage interaction +vi.mock("../../persistence/encounter-storage.js", () => ({ + loadEncounter: () => null, + saveEncounter: () => {}, +})); + +vi.mock("../../persistence/player-character-storage.js", () => ({ + loadPlayerCharacters: () => [], + savePlayerCharacters: () => {}, +})); + +// Mock bestiary — no IndexedDB or JSON index +vi.mock("../../adapters/bestiary-cache.js", () => ({ + loadAllCachedCreatures: () => Promise.resolve(new Map()), + isSourceCached: () => Promise.resolve(false), + cacheSource: () => Promise.resolve(), + getCachedSources: () => Promise.resolve([]), + clearSource: () => Promise.resolve(), + clearAll: () => Promise.resolve(), +})); + +vi.mock("../../adapters/bestiary-index-adapter.js", () => ({ + loadBestiaryIndex: () => ({ sources: {}, creatures: [] }), + getAllSourceCodes: () => [], + getDefaultFetchUrl: () => "", + getSourceDisplayName: (code: string) => code, +})); + +// DOM API stubs — jsdom doesn't implement these +beforeAll(() => { + Object.defineProperty(globalThis, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); afterEach(cleanup); -const defaultProps = { - onAddCombatant: vi.fn(), - onAddFromBestiary: vi.fn(), - bestiarySearch: () => [], - bestiaryLoaded: false, -}; - -function renderBar(overrides: Partial[0]> = {}) { - const props = { ...defaultProps, ...overrides }; - return render(); +function renderBar(props: Partial[0]> = {}) { + return render(, { wrapper: AllProviders }); } describe("ActionBar", () => { @@ -26,26 +64,26 @@ describe("ActionBar", () => { expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument(); }); - it("submitting with a name calls onAddCombatant", async () => { + it("submitting with a name adds a combatant", async () => { const user = userEvent.setup(); - const onAddCombatant = vi.fn(); - renderBar({ onAddCombatant }); + renderBar(); const input = screen.getByPlaceholderText("+ Add combatants"); await user.type(input, "Goblin"); // The Add button appears when name >= 2 chars and no suggestions const addButton = screen.getByRole("button", { name: "Add" }); await user.click(addButton); - expect(onAddCombatant).toHaveBeenCalledWith("Goblin", undefined); + // Input is cleared after adding (context handles the state) + expect(input).toHaveValue(""); }); it("submitting with empty name does nothing", async () => { const user = userEvent.setup(); - const onAddCombatant = vi.fn(); - renderBar({ onAddCombatant }); + renderBar(); // Submit the form directly (Enter on empty input) const input = screen.getByPlaceholderText("+ Add combatants"); await user.type(input, "{Enter}"); - expect(onAddCombatant).not.toHaveBeenCalled(); + // Input stays empty, no error + expect(input).toHaveValue(""); }); it("shows custom fields (Init, AC, MaxHP) when name >= 2 chars and no bestiary suggestions", async () => { @@ -66,23 +104,18 @@ describe("ActionBar", () => { expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument(); }); - it("shows roll all initiative button when showRollAllInitiative is true", () => { - const onRollAllInitiative = vi.fn(); - renderBar({ showRollAllInitiative: true, onRollAllInitiative }); + it("does not show roll all initiative button when no creature combatants", () => { + renderBar(); expect( - screen.getByRole("button", { name: "Roll all initiative" }), - ).toBeInTheDocument(); + screen.queryByRole("button", { name: "Roll all initiative" }), + ).not.toBeInTheDocument(); }); - it("roll all initiative button is disabled when rollAllInitiativeDisabled is true", () => { - const onRollAllInitiative = vi.fn(); - renderBar({ - showRollAllInitiative: true, - onRollAllInitiative, - rollAllInitiativeDisabled: true, - }); + it("shows overflow menu items", () => { + renderBar({ onManagePlayers: vi.fn() }); + // The overflow menu should be present (it contains Player Characters etc.) expect( - screen.getByRole("button", { name: "Roll all initiative" }), - ).toBeDisabled(); + screen.getByRole("button", { name: "More actions" }), + ).toBeInTheDocument(); }); }); diff --git a/apps/web/src/components/__tests__/combatant-row.test.tsx b/apps/web/src/components/__tests__/combatant-row.test.tsx index 9850f49..13ca899 100644 --- a/apps/web/src/components/__tests__/combatant-row.test.tsx +++ b/apps/web/src/components/__tests__/combatant-row.test.tsx @@ -1,33 +1,65 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; -import { combatantId } from "@initiative/domain"; +import { type CreatureId, combatantId } from "@initiative/domain"; import { cleanup, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { CombatantRow } from "../combatant-row"; -import { PLAYER_COLOR_HEX } from "../player-icon-map"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { AllProviders } from "../../__tests__/test-providers.js"; +import { CombatantRow } from "../combatant-row.js"; +import { PLAYER_COLOR_HEX } from "../player-icon-map.js"; + +// Mock persistence — no localStorage interaction +vi.mock("../../persistence/encounter-storage.js", () => ({ + loadEncounter: () => null, + saveEncounter: () => {}, +})); + +vi.mock("../../persistence/player-character-storage.js", () => ({ + loadPlayerCharacters: () => [], + savePlayerCharacters: () => {}, +})); + +// Mock bestiary — no IndexedDB or JSON index +vi.mock("../../adapters/bestiary-cache.js", () => ({ + loadAllCachedCreatures: () => Promise.resolve(new Map()), + isSourceCached: () => Promise.resolve(false), + cacheSource: () => Promise.resolve(), + getCachedSources: () => Promise.resolve([]), + clearSource: () => Promise.resolve(), + clearAll: () => Promise.resolve(), +})); + +vi.mock("../../adapters/bestiary-index-adapter.js", () => ({ + loadBestiaryIndex: () => ({ sources: {}, creatures: [] }), + getAllSourceCodes: () => [], + getDefaultFetchUrl: () => "", + getSourceDisplayName: (code: string) => code, +})); + +// DOM API stubs +beforeAll(() => { + Object.defineProperty(globalThis, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); afterEach(cleanup); -const defaultProps = { - onRename: vi.fn(), - onSetInitiative: vi.fn(), - onRemove: vi.fn(), - onSetHp: vi.fn(), - onAdjustHp: vi.fn(), - onSetAc: vi.fn(), - onToggleCondition: vi.fn(), - onToggleConcentration: vi.fn(), -}; - function renderRow( overrides: Partial<{ combatant: Parameters[0]["combatant"]; isActive: boolean; - onRollInitiative: (id: ReturnType) => void; - onRemove: (id: ReturnType) => void; - onShowStatBlock: () => void; }> = {}, ) { const combatant = overrides.combatant ?? { @@ -38,15 +70,13 @@ function renderRow( currentHp: 10, ac: 13, }; - const props = { - ...defaultProps, - combatant, - isActive: overrides.isActive ?? false, - onRollInitiative: overrides.onRollInitiative, - onShowStatBlock: overrides.onShowStatBlock, - onRemove: overrides.onRemove ?? defaultProps.onRemove, - }; - return render(); + return render( + , + { wrapper: AllProviders }, + ); } describe("CombatantRow", () => { @@ -132,10 +162,9 @@ describe("CombatantRow", () => { expect(svgIcon).toHaveStyle({ color: PLAYER_COLOR_HEX.red }); }); - it("remove button calls onRemove after confirmation", async () => { + it("remove button removes after confirmation", async () => { const user = userEvent.setup(); - const onRemove = vi.fn(); - renderRow({ onRemove }); + renderRow(); const removeBtn = screen.getByRole("button", { name: "Remove combatant", }); @@ -146,16 +175,19 @@ describe("CombatantRow", () => { name: "Confirm remove combatant", }); await user.click(confirmBtn); - expect(onRemove).toHaveBeenCalledWith(combatantId("1")); + // After confirming, the button returns to its initial state + expect( + screen.queryByRole("button", { name: "Confirm remove combatant" }), + ).not.toBeInTheDocument(); }); - it("shows d20 roll button when initiative is undefined and onRollInitiative is provided", () => { + it("shows d20 roll button when initiative is undefined and combatant has creatureId", () => { renderRow({ combatant: { id: combatantId("1"), name: "Goblin", + creatureId: "srd:goblin" as CreatureId, }, - onRollInitiative: vi.fn(), }); expect( screen.getByRole("button", { name: "Roll initiative" }), diff --git a/apps/web/src/components/__tests__/source-manager.test.tsx b/apps/web/src/components/__tests__/source-manager.test.tsx index e0a4d2a..d5d6608 100644 --- a/apps/web/src/components/__tests__/source-manager.test.tsx +++ b/apps/web/src/components/__tests__/source-manager.test.tsx @@ -11,28 +11,51 @@ vi.mock("../../adapters/bestiary-cache.js", () => ({ clearAll: vi.fn(), })); +// Mock the context module +vi.mock("../../contexts/bestiary-context.js", () => ({ + useBestiaryContext: vi.fn(), +})); + import * as bestiaryCache from "../../adapters/bestiary-cache.js"; -import { SourceManager } from "../source-manager"; +import { useBestiaryContext } from "../../contexts/bestiary-context.js"; +import { SourceManager } from "../source-manager.js"; const mockGetCachedSources = vi.mocked(bestiaryCache.getCachedSources); const mockClearSource = vi.mocked(bestiaryCache.clearSource); const mockClearAll = vi.mocked(bestiaryCache.clearAll); +const mockUseBestiaryContext = vi.mocked(useBestiaryContext); afterEach(() => { cleanup(); vi.clearAllMocks(); }); +function setupMockContext() { + const refreshCache = vi.fn().mockResolvedValue(undefined); + mockUseBestiaryContext.mockReturnValue({ + refreshCache, + search: vi.fn().mockReturnValue([]), + getCreature: vi.fn(), + isLoaded: true, + isSourceCached: vi.fn().mockResolvedValue(false), + fetchAndCacheSource: vi.fn(), + uploadAndCacheSource: vi.fn(), + } as ReturnType); + return { refreshCache }; +} + describe("SourceManager", () => { it("shows 'No cached sources' empty state when no sources", async () => { + setupMockContext(); mockGetCachedSources.mockResolvedValue([]); - render(); + render(); await waitFor(() => { expect(screen.getByText("No cached sources")).toBeInTheDocument(); }); }); it("lists cached sources with display name and creature count", async () => { + setupMockContext(); mockGetCachedSources.mockResolvedValue([ { sourceCode: "mm", @@ -47,7 +70,7 @@ describe("SourceManager", () => { cachedAt: Date.now(), }, ]); - render(); + render(); await waitFor(() => { expect(screen.getByText("Monster Manual")).toBeInTheDocument(); }); @@ -56,9 +79,9 @@ describe("SourceManager", () => { expect(screen.getByText("100 creatures")).toBeInTheDocument(); }); - it("Clear All button calls cache clear and onCacheCleared", async () => { + it("Clear All button calls cache clear and refreshCache", async () => { const user = userEvent.setup(); - const onCacheCleared = vi.fn(); + const { refreshCache } = setupMockContext(); mockGetCachedSources .mockResolvedValueOnce([ { @@ -70,7 +93,7 @@ describe("SourceManager", () => { ]) .mockResolvedValue([]); mockClearAll.mockResolvedValue(undefined); - render(); + render(); await waitFor(() => { expect(screen.getByText("Monster Manual")).toBeInTheDocument(); @@ -80,12 +103,12 @@ describe("SourceManager", () => { await waitFor(() => { expect(mockClearAll).toHaveBeenCalled(); }); - expect(onCacheCleared).toHaveBeenCalled(); + expect(refreshCache).toHaveBeenCalled(); }); it("individual source delete button calls clear for that source", async () => { const user = userEvent.setup(); - const onCacheCleared = vi.fn(); + const { refreshCache } = setupMockContext(); mockGetCachedSources .mockResolvedValueOnce([ { @@ -111,7 +134,7 @@ describe("SourceManager", () => { ]); mockClearSource.mockResolvedValue(undefined); - render(); + render(); await waitFor(() => { expect(screen.getByText("Monster Manual")).toBeInTheDocument(); }); @@ -122,6 +145,6 @@ describe("SourceManager", () => { await waitFor(() => { expect(mockClearSource).toHaveBeenCalledWith("mm"); }); - expect(onCacheCleared).toHaveBeenCalled(); + expect(refreshCache).toHaveBeenCalled(); }); }); diff --git a/apps/web/src/components/__tests__/turn-navigation.test.tsx b/apps/web/src/components/__tests__/turn-navigation.test.tsx index 9fff37d..70e2082 100644 --- a/apps/web/src/components/__tests__/turn-navigation.test.tsx +++ b/apps/web/src/components/__tests__/turn-navigation.test.tsx @@ -5,11 +5,23 @@ import type { Encounter } from "@initiative/domain"; import { combatantId } from "@initiative/domain"; import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { TurnNavigation } from "../turn-navigation"; -afterEach(cleanup); +// Mock the context module +vi.mock("../../contexts/encounter-context.js", () => ({ + useEncounterContext: vi.fn(), +})); -function renderNav(overrides: Partial = {}) { +import { useEncounterContext } from "../../contexts/encounter-context.js"; +import { TurnNavigation } from "../turn-navigation.js"; + +const mockUseEncounterContext = vi.mocked(useEncounterContext); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +function mockContext(overrides: Partial = {}) { const encounter: Encounter = { combatants: [ { id: combatantId("1"), name: "Goblin" }, @@ -20,14 +32,38 @@ function renderNav(overrides: Partial = {}) { ...overrides, }; - return render( - , + const value = { + encounter, + advanceTurn: vi.fn(), + retreatTurn: vi.fn(), + clearEncounter: vi.fn(), + isEmpty: encounter.combatants.length === 0, + hasCreatureCombatants: false, + canRollAllInitiative: false, + addCombatant: vi.fn(), + removeCombatant: vi.fn(), + editCombatant: vi.fn(), + setInitiative: vi.fn(), + setHp: vi.fn(), + adjustHp: vi.fn(), + setAc: vi.fn(), + toggleCondition: vi.fn(), + toggleConcentration: vi.fn(), + addFromBestiary: vi.fn(), + addFromPlayerCharacter: vi.fn(), + makeStore: vi.fn(), + events: [], + }; + + mockUseEncounterContext.mockReturnValue( + value as ReturnType, ); + return value; +} + +function renderNav(overrides: Partial = {}) { + mockContext(overrides); + return render(); } describe("TurnNavigation", () => { @@ -49,7 +85,7 @@ describe("TurnNavigation", () => { it("does not render an em dash between round and name", () => { const { container } = renderNav(); - expect(container.textContent).not.toContain("—"); + expect(container.textContent).not.toContain("\u2014"); }); it("round badge and combatant name are siblings in the center area", () => { @@ -60,69 +96,27 @@ describe("TurnNavigation", () => { }); it("updates the round badge when round changes", () => { - const { rerender } = render( - , - ); + mockContext({ roundNumber: 2 }); + const { rerender } = render(); expect(screen.getByText("R2")).toBeInTheDocument(); - rerender( - , - ); + mockContext({ roundNumber: 3 }); + rerender(); expect(screen.getByText("R3")).toBeInTheDocument(); expect(screen.queryByText("R2")).not.toBeInTheDocument(); }); it("renders the next combatant name when turn advances", () => { - const { rerender } = render( - , - ); + const combatants = [ + { id: combatantId("1"), name: "Goblin" }, + { id: combatantId("2"), name: "Conjurer" }, + ]; + mockContext({ combatants, activeIndex: 0 }); + const { rerender } = render(); expect(screen.getByText("Goblin")).toBeInTheDocument(); - rerender( - , - ); + mockContext({ combatants, activeIndex: 1 }); + rerender(); expect(screen.getByText("Conjurer")).toBeInTheDocument(); }); }); diff --git a/apps/web/src/components/action-bar.tsx b/apps/web/src/components/action-bar.tsx index cc55cdf..f50608e 100644 --- a/apps/web/src/components/action-bar.tsx +++ b/apps/web/src/components/action-bar.tsx @@ -1,4 +1,4 @@ -import type { PlayerCharacter, RollMode } from "@initiative/domain"; +import type { CreatureId, PlayerCharacter } from "@initiative/domain"; import { Check, Eye, @@ -18,11 +18,18 @@ import React, { useDeferredValue, useState, } from "react"; -import type { SearchResult } from "../hooks/use-bestiary.js"; +import type { SearchResult } from "../contexts/bestiary-context.js"; +import { useBestiaryContext } from "../contexts/bestiary-context.js"; +import { useBulkImportContext } from "../contexts/bulk-import-context.js"; +import { useEncounterContext } from "../contexts/encounter-context.js"; +import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js"; +import { usePlayerCharactersContext } from "../contexts/player-characters-context.js"; +import { useSidePanelContext } from "../contexts/side-panel-context.js"; +import { useThemeContext } from "../contexts/theme-context.js"; import { useLongPress } from "../hooks/use-long-press.js"; import { cn } from "../lib/utils.js"; import { D20Icon } from "./d20-icon.js"; -import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map"; +import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js"; import { RollModeMenu } from "./roll-mode-menu.js"; import { Button } from "./ui/button.js"; import { Input } from "./ui/input.js"; @@ -34,27 +41,9 @@ interface QueuedCreature { } interface ActionBarProps { - 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; inputRef?: RefObject; - playerCharacters?: readonly PlayerCharacter[]; - onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void; - onManagePlayers?: () => void; - onRollAllInitiative?: (mode?: RollMode) => void; - showRollAllInitiative?: boolean; - rollAllInitiativeDisabled?: boolean; - onOpenSourceManager?: () => void; autoFocus?: boolean; - themePreference?: "system" | "light" | "dark"; - onCycleTheme?: () => void; + onManagePlayers?: () => void; } function creatureKey(r: SearchResult): string { @@ -285,25 +274,48 @@ function buildOverflowItems(opts: { } export function ActionBar({ - onAddCombatant, - onAddFromBestiary, - bestiarySearch, - bestiaryLoaded, - onViewStatBlock, - onBulkImport, - bulkImportDisabled, inputRef, - playerCharacters, - onAddFromPlayerCharacter, - onManagePlayers, - onRollAllInitiative, - showRollAllInitiative, - rollAllInitiativeDisabled, - onOpenSourceManager, autoFocus, - themePreference, - onCycleTheme, + onManagePlayers, }: Readonly) { + const { + addCombatant, + addFromBestiary, + addFromPlayerCharacter, + hasCreatureCombatants, + canRollAllInitiative, + } = useEncounterContext(); + const { search: bestiarySearch, isLoaded: bestiaryLoaded } = + useBestiaryContext(); + const { characters: playerCharacters } = usePlayerCharactersContext(); + const { showBulkImport, showSourceManager, showCreature, panelView } = + useSidePanelContext(); + const { preference: themePreference, cycleTheme } = useThemeContext(); + const { handleRollAllInitiative } = useInitiativeRollsContext(); + const { state: bulkImportState } = useBulkImportContext(); + + const handleAddFromBestiary = useCallback( + (result: SearchResult) => { + const creatureId = addFromBestiary(result); + if (creatureId && panelView.mode === "closed") { + showCreature(creatureId); + } + }, + [addFromBestiary, panelView.mode, showCreature], + ); + + const handleViewStatBlock = useCallback( + (result: SearchResult) => { + const slug = result.name + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, "-") + .replaceAll(/(^-|-$)/g, ""); + const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId; + showCreature(cId); + }, + [showCreature], + ); + const [nameInput, setNameInput] = useState(""); const [suggestions, setSuggestions] = useState([]); const [pcMatches, setPcMatches] = useState([]); @@ -340,7 +352,7 @@ export function ActionBar({ const confirmQueued = () => { if (!queued) return; for (let i = 0; i < queued.count; i++) { - onAddFromBestiary(queued.result); + handleAddFromBestiary(queued.result); } clearInput(); }; @@ -366,7 +378,7 @@ export function ActionBar({ 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); + addCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined); setNameInput(""); setSuggestions([]); setPcMatches([]); @@ -468,14 +480,14 @@ export function ActionBar({ setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1)); } else if (e.key === "Enter" && suggestionIndex >= 0) { e.preventDefault(); - onViewStatBlock?.(suggestions[suggestionIndex]); + handleViewStatBlock(suggestions[suggestionIndex]); setBrowseMode(false); clearInput(); } }; const handleBrowseSelect = (result: SearchResult) => { - onViewStatBlock?.(result); + handleViewStatBlock(result); setBrowseMode(false); clearInput(); }; @@ -507,12 +519,12 @@ export function ActionBar({ const overflowItems = buildOverflowItems({ onManagePlayers, - onOpenSourceManager, + onOpenSourceManager: showSourceManager, bestiaryLoaded, - onBulkImport, - bulkImportDisabled, + onBulkImport: showBulkImport, + bulkImportDisabled: bulkImportState.status === "loading", themePreference, - onCycleTheme, + onCycleTheme: cycleTheme, }); return ( @@ -535,7 +547,7 @@ export function ActionBar({ className="pr-8" autoFocus={autoFocus} /> - {bestiaryLoaded && !!onViewStatBlock && ( + {!!bestiaryLoaded && ( )} - {showRollAllInitiative && !!onRollAllInitiative && ( + {!!hasCreatureCombatants && ( <> + ); } @@ -41,7 +47,7 @@ export function BulkImportPrompt({ Loaded {importState.completed}/{importState.total} sources ( {importState.failed} failed) - + ); } @@ -96,7 +102,7 @@ export function BulkImportPrompt({ /> - diff --git a/apps/web/src/components/bulk-import-toasts.tsx b/apps/web/src/components/bulk-import-toasts.tsx index 106156d..996cdc6 100644 --- a/apps/web/src/components/bulk-import-toasts.tsx +++ b/apps/web/src/components/bulk-import-toasts.tsx @@ -1,17 +1,12 @@ -import type { BulkImportState } from "../hooks/use-bulk-import.js"; +import { useBulkImportContext } from "../contexts/bulk-import-context.js"; +import { useSidePanelContext } from "../contexts/side-panel-context.js"; import { Toast } from "./toast.js"; -interface BulkImportToastsProps { - state: BulkImportState; - visible: boolean; - onReset: () => void; -} +export function BulkImportToasts() { + const { state, reset } = useBulkImportContext(); + const { bulkImportMode, isRightPanelCollapsed } = useSidePanelContext(); + const visible = !bulkImportMode || isRightPanelCollapsed; -export function BulkImportToasts({ - state, - visible, - onReset, -}: Readonly) { if (!visible) return null; if (state.status === "loading") { @@ -30,7 +25,7 @@ export function BulkImportToasts({ return ( ); @@ -40,7 +35,7 @@ export function BulkImportToasts({ return ( ); } diff --git a/apps/web/src/components/combatant-row.tsx b/apps/web/src/components/combatant-row.tsx index f7473a2..4f32e75 100644 --- a/apps/web/src/components/combatant-row.tsx +++ b/apps/web/src/components/combatant-row.tsx @@ -1,23 +1,27 @@ import { type CombatantId, type ConditionId, + type CreatureId, deriveHpStatus, type PlayerIcon, type RollMode, } from "@initiative/domain"; import { Book, BookOpen, Brain, X } from "lucide-react"; import { type Ref, useCallback, useEffect, useRef, useState } from "react"; -import { useLongPress } from "../hooks/use-long-press"; -import { cn } from "../lib/utils"; -import { AcShield } from "./ac-shield"; -import { ConditionPicker } from "./condition-picker"; -import { ConditionTags } from "./condition-tags"; -import { D20Icon } from "./d20-icon"; -import { HpAdjustPopover } from "./hp-adjust-popover"; -import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map"; -import { RollModeMenu } from "./roll-mode-menu"; -import { ConfirmButton } from "./ui/confirm-button"; -import { Input } from "./ui/input"; +import { useEncounterContext } from "../contexts/encounter-context.js"; +import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js"; +import { useSidePanelContext } from "../contexts/side-panel-context.js"; +import { useLongPress } from "../hooks/use-long-press.js"; +import { cn } from "../lib/utils.js"; +import { AcShield } from "./ac-shield.js"; +import { ConditionPicker } from "./condition-picker.js"; +import { ConditionTags } from "./condition-tags.js"; +import { D20Icon } from "./d20-icon.js"; +import { HpAdjustPopover } from "./hp-adjust-popover.js"; +import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js"; +import { RollModeMenu } from "./roll-mode-menu.js"; +import { ConfirmButton } from "./ui/confirm-button.js"; +import { Input } from "./ui/input.js"; interface Combatant { readonly id: CombatantId; @@ -30,22 +34,12 @@ interface Combatant { readonly isConcentrating?: boolean; readonly color?: string; readonly icon?: string; + readonly creatureId?: CreatureId; } interface CombatantRowProps { combatant: Combatant; isActive: boolean; - onRename: (id: CombatantId, newName: string) => void; - onSetInitiative: (id: CombatantId, value: number | undefined) => void; - onRemove: (id: CombatantId) => void; - onSetHp: (id: CombatantId, maxHp: number | undefined) => void; - onAdjustHp: (id: CombatantId, delta: number) => void; - onSetAc: (id: CombatantId, value: number | undefined) => void; - onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void; - onToggleConcentration: (id: CombatantId) => void; - onShowStatBlock?: () => void; - isStatBlockOpen?: boolean; - onRollInitiative?: (id: CombatantId, mode?: RollMode) => void; } function EditableName({ @@ -346,7 +340,7 @@ function InitiativeDisplay({ ); } - // Empty + bestiary creature → d20 roll button + // Empty + bestiary creature -> d20 roll button if (initiative === undefined && onRollInitiative) { return ( <> @@ -378,8 +372,8 @@ function InitiativeDisplay({ ); } - // Has value → bold number, click to edit - // Empty + manual → "--" placeholder, click to edit + // Has value -> bold number, click to edit + // Empty + manual -> "--" placeholder, click to edit return (