Refactor App.tsx from god component to context-based architecture
All checks were successful
CI / check (push) Successful in 1m18s
CI / build-image (push) Has been skipped

Replace prop drilling with React context providers. App.tsx shrinks
from 427 lines to ~80 lines of pure layout. Components consume shared
state directly via 7 context providers instead of threading 50+ props.

Key changes:
- 7 context providers wrapping existing hooks (encounter, bestiary,
  player characters, side panel, theme, bulk import, initiative rolls)
- 2 coordinating hooks extracted from App.tsx (useInitiativeRolls,
  useAutoStatBlock)
- All 9 affected components refactored from prop-based to context-based
- 6 test files updated to use providers or context mocks
- Prop count enforcement script (max 8 per component interface)
- Constitution principle II-A added (context-based state flow)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-19 14:19:58 +01:00
parent 6584d8d064
commit 8828edf647
34 changed files with 1026 additions and 758 deletions

View File

@@ -1,9 +1,9 @@
<!--
Sync Impact Report
───────────────────
Version change: 2.2.1 → 3.0.0 (MAJOR — specs describe features not changes, proportional workflow)
Version change: 3.0.0 → 3.1.0 (MINOR — new principle II-A: context-based state flow)
Modified sections:
- Development Workflow: specs are living feature documents; full pipeline for new features only
- Core Principles: added II-A. Context-Based State Flow (max 8 props, context over prop drilling)
Templates requiring updates: none
-->
# 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

View File

@@ -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

View File

@@ -1,42 +1,19 @@
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, useLayoutEffect, useRef, useState } 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;
}
} 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 { useAutoStatBlock } from "./hooks/use-auto-stat-block.js";
import { cn } from "./lib/utils.js";
function useActionBarAnimation(combatantCount: number) {
const wasEmptyRef = useRef(combatantCount === 0);
@@ -76,164 +53,27 @@ function useActionBarAnimation(combatantCount: number) {
}
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<HTMLInputElement>(null);
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
const actionBarInputRef = useRef<HTMLInputElement>(null);
const activeRowRef = useRef<HTMLDivElement>(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<HTMLDivElement>(null);
useEffect(() => {
activeRowRef.current?.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}, []);
}, [activeIndex]);
return (
<div className="flex h-dvh flex-col">
@@ -243,49 +83,27 @@ export function App() {
className={cn("shrink-0 pt-8", actionBarAnim.topBarClass)}
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
>
<TurnNavigation
encounter={encounter}
onAdvanceTurn={advanceTurn}
onRetreatTurn={retreatTurn}
onClearEncounter={clearEncounter}
/>
<TurnNavigation />
</div>
)}
{isEmpty ? (
/* Empty state — ActionBar centered */
<div className="flex min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]">
<div
className={cn("w-full", actionBarAnim.risingClass)}
onAnimationEnd={actionBarAnim.onRiseEnd}
>
<ActionBar
onAddCombatant={addCombatant}
onAddFromBestiary={handleAddFromBestiary}
bestiarySearch={search}
bestiaryLoaded={isLoaded}
onViewStatBlock={handleViewStatBlock}
onBulkImport={sidePanel.showBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"}
inputRef={actionBarInputRef}
playerCharacters={playerCharacters}
onAddFromPlayerCharacter={addFromPlayerCharacter}
onManagePlayers={() =>
playerCharacterRef.current?.openManagement()
}
onRollAllInitiative={handleRollAllInitiative}
showRollAllInitiative={hasCreatureCombatants}
rollAllInitiativeDisabled={!canRollAllInitiative}
onOpenSourceManager={sidePanel.showSourceManager}
themePreference={themePreference}
onCycleTheme={cycleTheme}
autoFocus
/>
</div>
</div>
) : (
<>
{/* Scrollable area — combatant list */}
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="flex flex-col px-2 py-2">
{encounter.combatants.map((c, i) => (
@@ -294,133 +112,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
}
/>
))}
</div>
</div>
{/* Action Bar — fixed at bottom */}
<div
className={cn("shrink-0 pb-8", actionBarAnim.settlingClass)}
onAnimationEnd={actionBarAnim.onSettleEnd}
>
<ActionBar
onAddCombatant={addCombatant}
onAddFromBestiary={handleAddFromBestiary}
bestiarySearch={search}
bestiaryLoaded={isLoaded}
onViewStatBlock={handleViewStatBlock}
onBulkImport={sidePanel.showBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"}
inputRef={actionBarInputRef}
playerCharacters={playerCharacters}
onAddFromPlayerCharacter={addFromPlayerCharacter}
onManagePlayers={() =>
playerCharacterRef.current?.openManagement()
}
onRollAllInitiative={handleRollAllInitiative}
showRollAllInitiative={hasCreatureCombatants}
rollAllInitiativeDisabled={!canRollAllInitiative}
onOpenSourceManager={sidePanel.showSourceManager}
themePreference={themePreference}
onCycleTheme={cycleTheme}
/>
</div>
</>
)}
</div>
{/* Pinned Stat Block Panel (left) */}
{!!sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
<StatBlockPanel
creatureId={sidePanel.pinnedCreatureId}
creature={pinnedCreature}
isSourceCached={isSourceCached}
fetchAndCacheSource={fetchAndCacheSource}
uploadAndCacheSource={uploadAndCacheSource}
refreshCache={refreshCache}
panelRole="pinned"
isCollapsed={false}
onToggleCollapse={() => {}}
onPin={() => {}}
onUnpin={sidePanel.unpin}
showPinButton={false}
side="left"
onDismiss={() => {}}
/>
<StatBlockPanel panelRole="pinned" side="left" />
)}
{/* Browse Stat Block Panel (right) */}
<StatBlockPanel
creatureId={sidePanel.selectedCreatureId}
creature={selectedCreature}
isSourceCached={isSourceCached}
fetchAndCacheSource={fetchAndCacheSource}
uploadAndCacheSource={uploadAndCacheSource}
refreshCache={refreshCache}
panelRole="browse"
isCollapsed={sidePanel.isRightPanelCollapsed}
onToggleCollapse={sidePanel.toggleCollapse}
onPin={sidePanel.togglePin}
onUnpin={() => {}}
showPinButton={sidePanel.isWideDesktop && !!selectedCreature}
side="right"
onDismiss={sidePanel.dismissPanel}
bulkImportMode={sidePanel.bulkImportMode}
bulkImportState={bulkImport.state}
onStartBulkImport={handleStartBulkImport}
onBulkImportDone={handleBulkImportDone}
sourceManagerMode={sidePanel.sourceManagerMode}
/>
<StatBlockPanel panelRole="browse" side="right" />
<BulkImportToasts
state={bulkImport.state}
visible={!sidePanel.bulkImportMode || sidePanel.isRightPanelCollapsed}
onReset={bulkImport.reset}
/>
<BulkImportToasts />
{rollSkippedCount > 0 && (
{rolls.rollSkippedCount > 0 && (
<Toast
message={`${rollSkippedCount} skipped — bestiary source not loaded`}
onDismiss={() => setRollSkippedCount(0)}
message={`${rolls.rollSkippedCount} skipped — bestiary source not loaded`}
onDismiss={rolls.dismissRollSkipped}
autoDismissMs={4000}
/>
)}
{!!rollSingleSkipped && (
{!!rolls.rollSingleSkipped && (
<Toast
message="Can't roll — bestiary source not loaded"
onDismiss={() => setRollSingleSkipped(false)}
onDismiss={rolls.dismissRollSingleSkipped}
autoDismissMs={4000}
/>
)}
<PlayerCharacterSection
ref={playerCharacterRef}
characters={playerCharacters}
onCreateCharacter={createPlayerCharacter}
onEditCharacter={editPlayerCharacter}
onDeleteCharacter={deletePlayerCharacter}
/>
<PlayerCharacterSection ref={playerCharacterRef} />
</div>
);
}

View File

@@ -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(<App />);
render(<App />, { 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(<App />);
render(<App />, { 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(<App />);
render(<App />, { wrapper: AllProviders });
await addCombatant(user, "Ogre", { maxHp: "59" });

View File

@@ -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<typeof useSidePanelContext>);
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<typeof useBestiaryContext>);
render(<StatBlockPanel {...props} />);
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(<StatBlockPanel panelRole={panelRole} side={side} />);
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(
<StatBlockPanel
creatureId={CREATURE_ID}
creature={CREATURE}
isSourceCached={vi.fn().mockResolvedValue(true)}
fetchAndCacheSource={vi.fn()}
uploadAndCacheSource={vi.fn()}
refreshCache={vi.fn()}
panelRole="pinned"
isCollapsed={false}
onToggleCollapse={vi.fn()}
onPin={vi.fn()}
onUnpin={vi.fn()}
showPinButton={false}
side="left"
onDismiss={vi.fn()}
/>,
(() => {
setupMocks({ panelRole: "pinned" });
return <StatBlockPanel panelRole="pinned" side="left" />;
})(),
);
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(<StatBlockPanel panelRole="browse" side="right" />);
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(<StatBlockPanel panelRole="browse" side="right" />);
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",
});

View File

@@ -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 (
<ThemeProvider>
<EncounterProvider>
<BestiaryProvider>
<PlayerCharactersProvider>
<BulkImportProvider>
<SidePanelProvider>
<InitiativeRollsProvider>{children}</InitiativeRollsProvider>
</SidePanelProvider>
</BulkImportProvider>
</PlayerCharactersProvider>
</BestiaryProvider>
</EncounterProvider>
</ThemeProvider>
);
}

View File

@@ -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<Parameters<typeof ActionBar>[0]> = {}) {
const props = { ...defaultProps, ...overrides };
return render(<ActionBar {...props} />);
function renderBar(props: Partial<Parameters<typeof ActionBar>[0]> = {}) {
return render(<ActionBar {...props} />, { 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();
});
});

View File

@@ -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<typeof CombatantRow>[0]["combatant"];
isActive: boolean;
onRollInitiative: (id: ReturnType<typeof combatantId>) => void;
onRemove: (id: ReturnType<typeof combatantId>) => 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(<CombatantRow {...props} />);
return render(
<CombatantRow
combatant={combatant}
isActive={overrides.isActive ?? false}
/>,
{ 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" }),

View File

@@ -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<typeof useBestiaryContext>);
return { refreshCache };
}
describe("SourceManager", () => {
it("shows 'No cached sources' empty state when no sources", async () => {
setupMockContext();
mockGetCachedSources.mockResolvedValue([]);
render(<SourceManager onCacheCleared={vi.fn()} />);
render(<SourceManager />);
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(<SourceManager onCacheCleared={vi.fn()} />);
render(<SourceManager />);
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(<SourceManager onCacheCleared={onCacheCleared} />);
render(<SourceManager />);
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(<SourceManager onCacheCleared={onCacheCleared} />);
render(<SourceManager />);
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();
});
});

View File

@@ -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<Encounter> = {}) {
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<Encounter> = {}) {
const encounter: Encounter = {
combatants: [
{ id: combatantId("1"), name: "Goblin" },
@@ -20,14 +32,38 @@ function renderNav(overrides: Partial<Encounter> = {}) {
...overrides,
};
return render(
<TurnNavigation
encounter={encounter}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
/>,
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<typeof useEncounterContext>,
);
return value;
}
function renderNav(overrides: Partial<Encounter> = {}) {
mockContext(overrides);
return render(<TurnNavigation />);
}
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(
<TurnNavigation
encounter={{
combatants: [{ id: combatantId("1"), name: "Goblin" }],
activeIndex: 0,
roundNumber: 2,
}}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
/>,
);
mockContext({ roundNumber: 2 });
const { rerender } = render(<TurnNavigation />);
expect(screen.getByText("R2")).toBeInTheDocument();
rerender(
<TurnNavigation
encounter={{
combatants: [{ id: combatantId("1"), name: "Goblin" }],
activeIndex: 0,
roundNumber: 3,
}}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
/>,
);
mockContext({ roundNumber: 3 });
rerender(<TurnNavigation />);
expect(screen.getByText("R3")).toBeInTheDocument();
expect(screen.queryByText("R2")).not.toBeInTheDocument();
});
it("renders the next combatant name when turn advances", () => {
const { rerender } = render(
<TurnNavigation
encounter={{
combatants: [
{ id: combatantId("1"), name: "Goblin" },
{ id: combatantId("2"), name: "Conjurer" },
],
activeIndex: 0,
roundNumber: 1,
}}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
/>,
);
const combatants = [
{ id: combatantId("1"), name: "Goblin" },
{ id: combatantId("2"), name: "Conjurer" },
];
mockContext({ combatants, activeIndex: 0 });
const { rerender } = render(<TurnNavigation />);
expect(screen.getByText("Goblin")).toBeInTheDocument();
rerender(
<TurnNavigation
encounter={{
combatants: [
{ id: combatantId("1"), name: "Goblin" },
{ id: combatantId("2"), name: "Conjurer" },
],
activeIndex: 1,
roundNumber: 1,
}}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
/>,
);
mockContext({ combatants, activeIndex: 1 });
rerender(<TurnNavigation />);
expect(screen.getByText("Conjurer")).toBeInTheDocument();
});
});

View File

@@ -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<HTMLInputElement | null>;
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<ActionBarProps>) {
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<SearchResult[]>([]);
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
@@ -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 && (
<button
type="button"
tabIndex={-1}
@@ -596,7 +608,7 @@ export function ActionBar({
onSetSuggestionIndex={setSuggestionIndex}
onSetQueued={setQueued}
onConfirmQueued={confirmQueued}
onAddFromPlayerCharacter={onAddFromPlayerCharacter}
onAddFromPlayerCharacter={addFromPlayerCharacter}
/>
)}
</div>
@@ -632,20 +644,20 @@ export function ActionBar({
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
<Button type="submit">Add</Button>
)}
{showRollAllInitiative && !!onRollAllInitiative && (
{!!hasCreatureCombatants && (
<>
<Button
type="button"
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-hover-action"
onClick={() => onRollAllInitiative()}
onClick={() => handleRollAllInitiative()}
onContextMenu={(e) => {
e.preventDefault();
openRollAllMenu(e.clientX, e.clientY);
}}
{...rollAllLongPress}
disabled={rollAllInitiativeDisabled}
disabled={!canRollAllInitiative}
title="Roll all initiative"
aria-label="Roll all initiative"
>
@@ -654,7 +666,7 @@ export function ActionBar({
{!!rollAllMenuPos && (
<RollModeMenu
position={rollAllMenuPos}
onSelect={(mode) => onRollAllInitiative(mode)}
onSelect={(mode) => handleRollAllInitiative(mode)}
onClose={() => setRollAllMenuPos(null)}
/>
)}

View File

@@ -1,35 +1,41 @@
import { Loader2 } from "lucide-react";
import { useId, useState } from "react";
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js";
import type { BulkImportState } from "../hooks/use-bulk-import.js";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
const DEFAULT_BASE_URL =
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
interface BulkImportPromptProps {
importState: BulkImportState;
onStartImport: (baseUrl: string) => void;
onDone: () => void;
}
export function BulkImportPrompt() {
const { fetchAndCacheSource, isSourceCached, refreshCache } =
useBestiaryContext();
const { state: importState, startImport, reset } = useBulkImportContext();
const { dismissPanel } = useSidePanelContext();
export function BulkImportPrompt({
importState,
onStartImport,
onDone,
}: Readonly<BulkImportPromptProps>) {
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
const baseUrlId = useId();
const totalSources = getAllSourceCodes().length;
const handleStart = (url: string) => {
startImport(url, fetchAndCacheSource, isSourceCached, refreshCache);
};
const handleDone = () => {
dismissPanel();
reset();
};
if (importState.status === "complete") {
return (
<div className="flex flex-col gap-4">
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-green-400 text-sm">
All sources loaded
</div>
<Button onClick={onDone}>Done</Button>
<Button onClick={handleDone}>Done</Button>
</div>
);
}
@@ -41,7 +47,7 @@ export function BulkImportPrompt({
Loaded {importState.completed}/{importState.total} sources (
{importState.failed} failed)
</div>
<Button onClick={onDone}>Done</Button>
<Button onClick={handleDone}>Done</Button>
</div>
);
}
@@ -96,7 +102,7 @@ export function BulkImportPrompt({
/>
</div>
<Button onClick={() => onStartImport(baseUrl)} disabled={isDisabled}>
<Button onClick={() => handleStart(baseUrl)} disabled={isDisabled}>
Load All
</Button>
</div>

View File

@@ -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<BulkImportToastsProps>) {
if (!visible) return null;
if (state.status === "loading") {
@@ -30,7 +25,7 @@ export function BulkImportToasts({
return (
<Toast
message="All sources loaded"
onDismiss={onReset}
onDismiss={reset}
autoDismissMs={3000}
/>
);
@@ -40,7 +35,7 @@ export function BulkImportToasts({
return (
<Toast
message={`Loaded ${state.completed}/${state.total} sources (${state.failed} failed)`}
onDismiss={onReset}
onDismiss={reset}
/>
);
}

View File

@@ -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 (
<button
type="button"
@@ -423,18 +417,30 @@ export function CombatantRow({
ref,
combatant,
isActive,
onRename,
onSetInitiative,
onRemove,
onSetHp,
onAdjustHp,
onSetAc,
onToggleCondition,
onToggleConcentration,
onShowStatBlock,
isStatBlockOpen,
onRollInitiative,
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
const {
editCombatant,
setInitiative,
removeCombatant,
setHp,
adjustHp,
setAc,
toggleCondition,
toggleConcentration,
} = useEncounterContext();
const { selectedCreatureId, showCreature } = useSidePanelContext();
const { handleRollInitiative } = useInitiativeRollsContext();
// Derive what was previously conditional props
const isStatBlockOpen = combatant.creatureId === selectedCreatureId;
const { creatureId } = combatant;
const onShowStatBlock = creatureId
? () => showCreature(creatureId)
: undefined;
const onRollInitiative = combatant.creatureId
? handleRollInitiative
: undefined;
const { id, name, initiative, maxHp, currentHp } = combatant;
const status = deriveHpStatus(currentHp, maxHp);
const dimmed = status === "unconscious";
@@ -484,7 +490,7 @@ export function CombatantRow({
{/* Concentration */}
<button
type="button"
onClick={() => onToggleConcentration(id)}
onClick={() => toggleConcentration(id)}
title="Concentrating"
aria-label="Toggle concentration"
className={cn(
@@ -500,7 +506,7 @@ export function CombatantRow({
initiative={initiative}
combatantId={id}
dimmed={dimmed}
onSetInitiative={onSetInitiative}
onSetInitiative={setInitiative}
onRollInitiative={onRollInitiative}
/>
@@ -541,18 +547,18 @@ export function CombatantRow({
<EditableName
name={name}
combatantId={id}
onRename={onRename}
onRename={editCombatant}
color={pcColor}
/>
<ConditionTags
conditions={combatant.conditions}
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
onRemove={(conditionId) => toggleCondition(id, conditionId)}
onOpenPicker={() => setPickerOpen((prev) => !prev)}
/>
{!!pickerOpen && (
<ConditionPicker
activeConditions={combatant.conditions}
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
onToggle={(conditionId) => toggleCondition(id, conditionId)}
onClose={() => setPickerOpen(false)}
/>
)}
@@ -560,7 +566,7 @@ export function CombatantRow({
{/* AC */}
<div className={cn(dimmed && "opacity-50")}>
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
<AcDisplay ac={combatant.ac} onCommit={(v) => setAc(id, v)} />
</div>
{/* HP */}
@@ -568,7 +574,7 @@ export function CombatantRow({
<ClickableHp
currentHp={currentHp}
maxHp={maxHp}
onAdjust={(delta) => onAdjustHp(id, delta)}
onAdjust={(delta) => adjustHp(id, delta)}
dimmed={dimmed}
/>
{maxHp !== undefined && (
@@ -582,7 +588,7 @@ export function CombatantRow({
</span>
)}
<div className={cn(dimmed && "opacity-50")}>
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => setHp(id, v)} />
</div>
</div>
@@ -590,7 +596,7 @@ export function CombatantRow({
<ConfirmButton
icon={<X size={16} />}
label="Remove combatant"
onConfirm={() => onRemove(id)}
onConfirm={() => removeCombatant(id)}
className="pointer-events-none pointer-coarse:pointer-events-auto h-7 w-7 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-opacity focus:pointer-events-auto focus:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
/>
</div>

View File

@@ -1,5 +1,6 @@
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
import type { PlayerCharacter } from "@initiative/domain";
import { type RefObject, useImperativeHandle, useState } from "react";
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
import { CreatePlayerModal } from "./create-player-modal.js";
import { PlayerManagement } from "./player-management.js";
@@ -7,37 +8,14 @@ export interface PlayerCharacterSectionHandle {
openManagement: () => void;
}
interface PlayerCharacterSectionProps {
characters: readonly PlayerCharacter[];
onCreateCharacter: (
name: string,
ac: number,
maxHp: number,
color: string | undefined,
icon: string | undefined,
) => void;
onEditCharacter: (
id: PlayerCharacterId,
fields: {
name?: string;
ac?: number;
maxHp?: number;
color?: string | null;
icon?: string | null;
},
) => void;
onDeleteCharacter: (id: PlayerCharacterId) => void;
}
export const PlayerCharacterSection = function PlayerCharacterSectionInner({
characters,
onCreateCharacter,
onEditCharacter,
onDeleteCharacter,
ref,
}: PlayerCharacterSectionProps & {
}: {
ref?: RefObject<PlayerCharacterSectionHandle | null>;
}) {
const { characters, createCharacter, editCharacter, deleteCharacter } =
usePlayerCharactersContext();
const [managementOpen, setManagementOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [editingPlayer, setEditingPlayer] = useState<
@@ -59,7 +37,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
}}
onSave={(name, ac, maxHp, color, icon) => {
if (editingPlayer) {
onEditCharacter(editingPlayer.id, {
editCharacter(editingPlayer.id, {
name,
ac,
maxHp,
@@ -67,7 +45,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
icon: icon ?? null,
});
} else {
onCreateCharacter(name, ac, maxHp, color, icon);
createCharacter(name, ac, maxHp, color, icon);
}
}}
playerCharacter={editingPlayer}
@@ -81,7 +59,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
setCreateOpen(true);
setManagementOpen(false);
}}
onDelete={(id) => onDeleteCharacter(id)}
onDelete={(id) => deleteCharacter(id)}
onCreate={() => {
setEditingPlayer(undefined);
setCreateOpen(true);

View File

@@ -1,24 +1,24 @@
import { Download, Loader2, Upload } from "lucide-react";
import { useId, useRef, useState } from "react";
import { getDefaultFetchUrl } from "../adapters/bestiary-index-adapter.js";
import {
getDefaultFetchUrl,
getSourceDisplayName,
} from "../adapters/bestiary-index-adapter.js";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
interface SourceFetchPromptProps {
sourceCode: string;
sourceDisplayName: string;
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
onSourceLoaded: () => void;
onUploadSource: (sourceCode: string, jsonData: unknown) => Promise<void>;
}
export function SourceFetchPrompt({
sourceCode,
sourceDisplayName,
fetchAndCacheSource,
onSourceLoaded,
onUploadSource,
}: Readonly<SourceFetchPromptProps>) {
const { fetchAndCacheSource, uploadAndCacheSource } = useBestiaryContext();
const sourceDisplayName = getSourceDisplayName(sourceCode);
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
const [error, setError] = useState<string>("");
@@ -47,7 +47,7 @@ export function SourceFetchPrompt({
try {
const text = await file.text();
const json = JSON.parse(text);
await onUploadSource(sourceCode, json);
await uploadAndCacheSource(sourceCode, json);
onSourceLoaded();
} catch (err) {
setStatus("error");

View File

@@ -8,16 +8,12 @@ import {
} from "react";
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
import * as bestiaryCache from "../adapters/bestiary-cache.js";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
interface SourceManagerProps {
onCacheCleared: () => void;
}
export function SourceManager({
onCacheCleared,
}: Readonly<SourceManagerProps>) {
export function SourceManager() {
const { refreshCache } = useBestiaryContext();
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
const [filter, setFilter] = useState("");
const [optimisticSources, applyOptimistic] = useOptimistic(
@@ -44,14 +40,14 @@ export function SourceManager({
applyOptimistic({ type: "remove", sourceCode });
await bestiaryCache.clearSource(sourceCode);
await loadSources();
onCacheCleared();
void refreshCache();
};
const handleClearAll = async () => {
applyOptimistic({ type: "clear" });
await bestiaryCache.clearAll();
await loadSources();
onCacheCleared();
void refreshCache();
};
const filteredSources = useMemo(() => {

View File

@@ -1,9 +1,9 @@
import type { Creature, CreatureId } from "@initiative/domain";
import type { CreatureId } from "@initiative/domain";
import { PanelRightClose, Pin, PinOff } from "lucide-react";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
import type { BulkImportState } from "../hooks/use-bulk-import.js";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js";
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
import { cn } from "../lib/utils.js";
import { BulkImportPrompt } from "./bulk-import-prompt.js";
@@ -13,28 +13,8 @@ import { StatBlock } from "./stat-block.js";
import { Button } from "./ui/button.js";
interface StatBlockPanelProps {
creatureId: CreatureId | null;
creature: Creature | null;
isSourceCached: (sourceCode: string) => Promise<boolean>;
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
uploadAndCacheSource: (
sourceCode: string,
jsonData: unknown,
) => Promise<void>;
refreshCache: () => Promise<void>;
panelRole: "browse" | "pinned";
isCollapsed: boolean;
onToggleCollapse: () => void;
onPin: () => void;
onUnpin: () => void;
showPinButton: boolean;
side: "left" | "right";
onDismiss: () => void;
bulkImportMode?: boolean;
bulkImportState?: BulkImportState;
onStartBulkImport?: (baseUrl: string) => void;
onBulkImportDone?: () => void;
sourceManagerMode?: boolean;
}
function extractSourceCode(cId: CreatureId): string {
@@ -228,27 +208,49 @@ function MobileDrawer({
);
}
function usePanelRole(panelRole: "browse" | "pinned") {
const sidePanel = useSidePanelContext();
const { getCreature } = useBestiaryContext();
const creatureId =
panelRole === "browse"
? sidePanel.selectedCreatureId
: sidePanel.pinnedCreatureId;
const creature = creatureId ? (getCreature(creatureId) ?? null) : null;
const isBrowse = panelRole === "browse";
return {
creatureId,
creature,
isCollapsed: isBrowse ? sidePanel.isRightPanelCollapsed : false,
onToggleCollapse: isBrowse ? sidePanel.toggleCollapse : () => {},
onDismiss: isBrowse ? sidePanel.dismissPanel : () => {},
onPin: isBrowse ? sidePanel.togglePin : () => {},
onUnpin: panelRole === "pinned" ? sidePanel.unpin : () => {},
showPinButton: isBrowse && sidePanel.isWideDesktop && !!creature,
bulkImportMode: isBrowse && sidePanel.bulkImportMode,
sourceManagerMode: isBrowse && sidePanel.sourceManagerMode,
};
}
export function StatBlockPanel({
creatureId,
creature,
isSourceCached,
fetchAndCacheSource,
uploadAndCacheSource,
refreshCache,
panelRole,
isCollapsed,
onToggleCollapse,
onPin,
onUnpin,
showPinButton,
side,
onDismiss,
bulkImportMode,
bulkImportState,
onStartBulkImport,
onBulkImportDone,
sourceManagerMode,
}: Readonly<StatBlockPanelProps>) {
const { isSourceCached } = useBestiaryContext();
const {
creatureId,
creature,
isCollapsed,
onToggleCollapse,
onDismiss,
onPin,
onUnpin,
showPinButton,
bulkImportMode,
sourceManagerMode,
} = usePanelRole(panelRole);
const [isDesktop, setIsDesktop] = useState(
() => globalThis.matchMedia("(min-width: 1024px)").matches,
);
@@ -285,29 +287,17 @@ export function StatBlockPanel({
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
const handleSourceLoaded = async () => {
await refreshCache();
const handleSourceLoaded = () => {
setNeedsFetch(false);
};
const renderContent = () => {
if (sourceManagerMode) {
return <SourceManager onCacheCleared={refreshCache} />;
return <SourceManager />;
}
if (
bulkImportMode &&
bulkImportState &&
onStartBulkImport &&
onBulkImportDone
) {
return (
<BulkImportPrompt
importState={bulkImportState}
onStartImport={onStartBulkImport}
onDone={onBulkImportDone}
/>
);
if (bulkImportMode) {
return <BulkImportPrompt />;
}
if (checkingCache) {
@@ -324,10 +314,7 @@ export function StatBlockPanel({
return (
<SourceFetchPrompt
sourceCode={sourceCode}
sourceDisplayName={getSourceDisplayName(sourceCode)}
fetchAndCacheSource={fetchAndCacheSource}
onSourceLoaded={handleSourceLoaded}
onUploadSource={uploadAndCacheSource}
/>
);
}

View File

@@ -1,21 +1,12 @@
import type { Encounter } from "@initiative/domain";
import { StepBack, StepForward, Trash2 } from "lucide-react";
import { Button } from "./ui/button";
import { ConfirmButton } from "./ui/confirm-button";
import { useEncounterContext } from "../contexts/encounter-context.js";
import { Button } from "./ui/button.js";
import { ConfirmButton } from "./ui/confirm-button.js";
interface TurnNavigationProps {
encounter: Encounter;
onAdvanceTurn: () => void;
onRetreatTurn: () => void;
onClearEncounter: () => void;
}
export function TurnNavigation() {
const { encounter, advanceTurn, retreatTurn, clearEncounter } =
useEncounterContext();
export function TurnNavigation({
encounter,
onAdvanceTurn,
onRetreatTurn,
onClearEncounter,
}: Readonly<TurnNavigationProps>) {
const hasCombatants = encounter.combatants.length > 0;
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
const activeCombatant = encounter.combatants[encounter.activeIndex];
@@ -25,7 +16,7 @@ export function TurnNavigation({
<Button
variant="ghost"
size="icon"
onClick={onRetreatTurn}
onClick={retreatTurn}
disabled={!hasCombatants || isAtStart}
title="Previous turn"
aria-label="Previous turn"
@@ -48,14 +39,14 @@ export function TurnNavigation({
<ConfirmButton
icon={<Trash2 className="h-5 w-5" />}
label="Clear encounter"
onConfirm={onClearEncounter}
onConfirm={clearEncounter}
disabled={!hasCombatants}
className="text-muted-foreground"
/>
<Button
variant="ghost"
size="icon"
onClick={onAdvanceTurn}
onClick={advanceTurn}
disabled={!hasCombatants}
title="Next turn"
aria-label="Next turn"

View File

@@ -0,0 +1,23 @@
import { createContext, type ReactNode, useContext } from "react";
import { useBestiary } from "../hooks/use-bestiary.js";
export type { SearchResult } from "../hooks/use-bestiary.js";
type BestiaryContextValue = ReturnType<typeof useBestiary>;
const BestiaryContext = createContext<BestiaryContextValue | null>(null);
export function BestiaryProvider({ children }: { children: ReactNode }) {
const value = useBestiary();
return (
<BestiaryContext.Provider value={value}>
{children}
</BestiaryContext.Provider>
);
}
export function useBestiaryContext(): BestiaryContextValue {
const ctx = useContext(BestiaryContext);
if (!ctx) throw new Error("useBestiaryContext requires BestiaryProvider");
return ctx;
}

View File

@@ -0,0 +1,21 @@
import { createContext, type ReactNode, useContext } from "react";
import { useBulkImport } from "../hooks/use-bulk-import.js";
type BulkImportContextValue = ReturnType<typeof useBulkImport>;
const BulkImportContext = createContext<BulkImportContextValue | null>(null);
export function BulkImportProvider({ children }: { children: ReactNode }) {
const value = useBulkImport();
return (
<BulkImportContext.Provider value={value}>
{children}
</BulkImportContext.Provider>
);
}
export function useBulkImportContext(): BulkImportContextValue {
const ctx = useContext(BulkImportContext);
if (!ctx) throw new Error("useBulkImportContext requires BulkImportProvider");
return ctx;
}

View File

@@ -0,0 +1,21 @@
import { createContext, type ReactNode, useContext } from "react";
import { useEncounter } from "../hooks/use-encounter.js";
type EncounterContextValue = ReturnType<typeof useEncounter>;
const EncounterContext = createContext<EncounterContextValue | null>(null);
export function EncounterProvider({ children }: { children: ReactNode }) {
const value = useEncounter();
return (
<EncounterContext.Provider value={value}>
{children}
</EncounterContext.Provider>
);
}
export function useEncounterContext(): EncounterContextValue {
const ctx = useContext(EncounterContext);
if (!ctx) throw new Error("useEncounterContext requires EncounterProvider");
return ctx;
}

View File

@@ -0,0 +1,7 @@
export { BestiaryProvider } from "./bestiary-context.js";
export { BulkImportProvider } from "./bulk-import-context.js";
export { EncounterProvider } from "./encounter-context.js";
export { InitiativeRollsProvider } from "./initiative-rolls-context.js";
export { PlayerCharactersProvider } from "./player-characters-context.js";
export { SidePanelProvider } from "./side-panel-context.js";
export { ThemeProvider } from "./theme-context.js";

View File

@@ -0,0 +1,25 @@
import { createContext, type ReactNode, useContext } from "react";
import { useInitiativeRolls } from "../hooks/use-initiative-rolls.js";
type InitiativeRollsContextValue = ReturnType<typeof useInitiativeRolls>;
const InitiativeRollsContext =
createContext<InitiativeRollsContextValue | null>(null);
export function InitiativeRollsProvider({ children }: { children: ReactNode }) {
const value = useInitiativeRolls();
return (
<InitiativeRollsContext.Provider value={value}>
{children}
</InitiativeRollsContext.Provider>
);
}
export function useInitiativeRollsContext(): InitiativeRollsContextValue {
const ctx = useContext(InitiativeRollsContext);
if (!ctx)
throw new Error(
"useInitiativeRollsContext requires InitiativeRollsProvider",
);
return ctx;
}

View File

@@ -0,0 +1,29 @@
import { createContext, type ReactNode, useContext } from "react";
import { usePlayerCharacters } from "../hooks/use-player-characters.js";
type PlayerCharactersContextValue = ReturnType<typeof usePlayerCharacters>;
const PlayerCharactersContext =
createContext<PlayerCharactersContextValue | null>(null);
export function PlayerCharactersProvider({
children,
}: {
children: ReactNode;
}) {
const value = usePlayerCharacters();
return (
<PlayerCharactersContext.Provider value={value}>
{children}
</PlayerCharactersContext.Provider>
);
}
export function usePlayerCharactersContext(): PlayerCharactersContextValue {
const ctx = useContext(PlayerCharactersContext);
if (!ctx)
throw new Error(
"usePlayerCharactersContext requires PlayerCharactersProvider",
);
return ctx;
}

View File

@@ -0,0 +1,21 @@
import { createContext, type ReactNode, useContext } from "react";
import { useSidePanelState } from "../hooks/use-side-panel-state.js";
type SidePanelContextValue = ReturnType<typeof useSidePanelState>;
const SidePanelContext = createContext<SidePanelContextValue | null>(null);
export function SidePanelProvider({ children }: { children: ReactNode }) {
const value = useSidePanelState();
return (
<SidePanelContext.Provider value={value}>
{children}
</SidePanelContext.Provider>
);
}
export function useSidePanelContext(): SidePanelContextValue {
const ctx = useContext(SidePanelContext);
if (!ctx) throw new Error("useSidePanelContext requires SidePanelProvider");
return ctx;
}

View File

@@ -0,0 +1,19 @@
import { createContext, type ReactNode, useContext } from "react";
import { useTheme } from "../hooks/use-theme.js";
type ThemeContextValue = ReturnType<typeof useTheme>;
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const value = useTheme();
return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
}
export function useThemeContext(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useThemeContext requires ThemeProvider");
return ctx;
}

View File

@@ -0,0 +1,17 @@
import { useEffect } from "react";
import { useEncounterContext } from "../contexts/encounter-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js";
export function useAutoStatBlock(): void {
const { encounter } = useEncounterContext();
const { panelView, updateCreature } = useSidePanelContext();
const activeCreatureId =
encounter.combatants[encounter.activeIndex]?.creatureId;
useEffect(() => {
if (activeCreatureId && panelView.mode === "creature") {
updateCreature(activeCreatureId);
}
}, [activeCreatureId, panelView.mode, updateCreature]);
}

View File

@@ -6,7 +6,7 @@ import {
const BATCH_SIZE = 6;
export interface BulkImportState {
interface BulkImportState {
readonly status: "idle" | "loading" | "complete" | "partial-failure";
readonly total: number;
readonly completed: number;

View File

@@ -0,0 +1,75 @@
import {
rollAllInitiativeUseCase,
rollInitiativeUseCase,
} from "@initiative/application";
import {
type CombatantId,
isDomainError,
type RollMode,
} from "@initiative/domain";
import { useCallback, useState } from "react";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useEncounterContext } from "../contexts/encounter-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js";
function rollDice(): number {
return Math.floor(Math.random() * 20) + 1;
}
export function useInitiativeRolls() {
const { encounter, makeStore } = useEncounterContext();
const { getCreature } = useBestiaryContext();
const { showCreature } = useSidePanelContext();
const [rollSkippedCount, setRollSkippedCount] = useState(0);
const [rollSingleSkipped, setRollSingleSkipped] = useState(false);
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) {
showCreature(combatant.creatureId);
}
}
},
[makeStore, getCreature, encounter.combatants, 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],
);
return {
rollSkippedCount,
rollSingleSkipped,
dismissRollSkipped: useCallback(() => setRollSkippedCount(0), []),
dismissRollSingleSkipped: useCallback(
() => setRollSingleSkipped(false),
[],
),
handleRollInitiative,
handleRollAllInitiative,
} as const;
}

View File

@@ -1,13 +1,36 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { App } from "./App.js";
import {
BestiaryProvider,
BulkImportProvider,
EncounterProvider,
InitiativeRollsProvider,
PlayerCharactersProvider,
SidePanelProvider,
ThemeProvider,
} from "./contexts/index.js";
import "./index.css";
const root = document.getElementById("root");
if (root) {
createRoot(root).render(
<StrictMode>
<App />
<ThemeProvider>
<EncounterProvider>
<BestiaryProvider>
<PlayerCharactersProvider>
<BulkImportProvider>
<SidePanelProvider>
<InitiativeRollsProvider>
<App />
</InitiativeRollsProvider>
</SidePanelProvider>
</BulkImportProvider>
</PlayerCharactersProvider>
</BestiaryProvider>
</EncounterProvider>
</ThemeProvider>
</StrictMode>,
);
}

View File

@@ -5,6 +5,6 @@
"entry": ["scripts/*.mjs"]
},
"packages/*": {},
"apps/*": {}
"apps/web": {}
}
}

View File

@@ -31,6 +31,7 @@
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
"check:ignores": "node scripts/check-lint-ignores.mjs",
"check:classnames": "node scripts/check-cn-classnames.mjs",
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && tsc --build && vitest run && jscpd"
"check:props": "node scripts/check-component-props.mjs",
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && node scripts/check-component-props.mjs && tsc --build && vitest run && jscpd"
}
}

View File

@@ -0,0 +1,99 @@
/**
* Enforce a maximum number of explicitly declared props per component
* interface.
*
* Components should consume shared application state via React context
* providers, not prop drilling. Props are reserved for per-instance
* configuration (a specific data item, a layout variant, a ref).
*
* Only scans component files (not hooks, adapters, etc.) and only
* counts properties declared directly in *Props interfaces — inherited
* or extended HTML attributes are not counted.
*/
import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { relative } from "node:path";
const MAX_PROPS = 8;
const files = execSync(
"git ls-files -- 'apps/web/src/components/*.tsx' 'apps/web/src/components/**/*.tsx'",
{ encoding: "utf-8" },
)
.trim()
.split("\n")
.filter(Boolean);
let errors = 0;
const propsRegex = /^(?:export\s+)?interface\s+(\w+Props)\s*\{/;
for (const file of files) {
const content = readFileSync(file, "utf-8");
const lines = content.split("\n");
let inInterface = false;
let interfaceName = "";
let braceDepth = 0;
let parenDepth = 0;
let propCount = 0;
let startLine = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!inInterface) {
const match = propsRegex.exec(line);
if (match) {
inInterface = true;
interfaceName = match[1];
braceDepth = 0;
parenDepth = 0;
propCount = 0;
startLine = i + 1;
}
}
if (inInterface) {
for (const ch of line) {
if (ch === "{") braceDepth++;
if (ch === "}") braceDepth--;
if (ch === "(") parenDepth++;
if (ch === ")") parenDepth--;
}
// Count prop lines at brace depth 1 and not inside function params:
// Matches " propName?: type" and " readonly propName: type"
if (
braceDepth === 1 &&
parenDepth === 0 &&
/^\s+(?:readonly\s+)?\w+\??\s*:/.test(line)
) {
propCount++;
}
if (braceDepth === 0) {
if (propCount > MAX_PROPS) {
const rel = relative(process.cwd(), file);
console.error(
`${rel}:${startLine}: ${interfaceName} has ${propCount} props (max ${MAX_PROPS})`,
);
errors++;
}
inInterface = false;
}
}
}
}
if (errors > 0) {
console.error(
`\n${errors} component(s) exceed the ${MAX_PROPS}-prop limit. Use React context to reduce props.`,
);
process.exit(1);
} else {
console.log(
`check-component-props: all component interfaces within ${MAX_PROPS}-prop limit`,
);
}