14 Commits
0.7.7 ... main

Author SHA1 Message Date
Lukas
86768842ff 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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 15:33:33 +01:00
Lukas
6584d8d064 Add advantage/disadvantage rolling for initiative
All checks were successful
CI / check (push) Successful in 1m23s
CI / build-image (push) Has been skipped
Right-click or long-press the d20 button (per-combatant or Roll All)
to open a context menu with Advantage and Disadvantage options.
Normal left-click behavior is unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:16:04 +01:00
Lukas
7f38cbab73 Preserve stat block panel collapsed state on turn advance
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 23:23:52 +01:00
Lukas
2971898f0c Add dark and light theme with OS preference support
All checks were successful
CI / check (push) Successful in 1m22s
CI / build-image (push) Successful in 36s
Follow OS color scheme by default, with a three-way toggle
(System / Light / Dark) in the kebab menu. Light theme uses warm,
neutral tones with soft card-to-background contrast. Semantic colors
(damage, healing, conditions) keep their hue across themes.

Closes #10

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 13:24:18 +01:00
Lukas
43780772f6 Improve bestiary icon UX and auto-update stat block on turn change
- Use Book/BookOpen icons to indicate stat block open state
- Bump combatant icons (bestiary + PC) from 14px to 16px
- Use text-foreground for bestiary icon visibility
- Auto-update stat block panel to active combatant's creature on turn advance
- Update bestiary spec edge case to reflect new behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 12:48:38 +01:00
Lukas
7b3dbe2069 Use ghost buttons for turn navigation to blend with top bar
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 12:18:31 +01:00
Lukas
827a3978e9 Show toast and open source panel when rolling initiative without loaded source
When a user clicks the d20 to roll initiative for a single combatant whose
bestiary source isn't cached, show an informative toast and open the stat
block panel so they can load the source directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 12:11:20 +01:00
Lukas
f024562a7d Auto-open stat block panel when adding first bestiary creature
When the side panel is in its initial closed state (not user-collapsed),
adding a combatant from the bestiary now opens the panel to show its
stat block. This makes the panel discoverable without overriding a
deliberate collapse.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 12:03:34 +01:00
Lukas
dfef2194a5 Add subtle radial gradient to app background
All checks were successful
CI / check (push) Successful in 1m25s
CI / build-image (push) Successful in 20s
Apply a soft blue radial glow centered on the viewport to add depth
to the dark background, replacing the flat solid color.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 14:27:38 +01:00
Lukas
502adca81b Fix layout shift on turn change and restore concentration border width
All checks were successful
CI / check (push) Successful in 1m26s
CI / build-image (push) Successful in 21s
Give all combatant rows a consistent border-l-2 + border on all sides
(transparent when inactive) so toggling active/concentration states
never changes the row's box size. Show purple left border when a
combatant is both active and concentrating.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 13:57:02 +01:00
Lukas
12e8bf6e69 Constrain rename input width to prevent row layout breakage
Cap the editable name input at max-w-48 so it doesn't stretch the
full column width and push icons/conditions onto separate lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 13:50:48 +01:00
Lukas
472574ac31 Bump border radius tokens for rounder UI surfaces
All checks were successful
CI / check (push) Successful in 1m23s
CI / build-image (push) Successful in 20s
Increase radius-md from 6px to 8px and radius-lg from 8px to 12px
for a more modern, polished look on buttons, inputs, and card surfaces.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 13:48:23 +01:00
Lukas
f4a7b53393 Restyle dark theme with blue-tinted palette, card glow, and rounded surfaces
Shift the dark theme from neutral gray to a richer blue-tinted palette
inspired by CharBuilder-style TTRPG apps. Deeper navy background, steel-blue
card surfaces, and visible blue borders create more depth and visual layering.

- Update design tokens: background, card, border, input, muted colors
- Add card-glow utility (radial gradient + blue box-shadow) for card surfaces
- Add panel-glow utility (top-down gradient) for tall panels like stat blocks
- Apply glow and rounded-lg to all card surfaces, dropdowns, dialogs, toasts
- Give outline buttons a subtle fill instead of transparent background
- Active combatant row now uses full border with glow instead of left accent

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 13:39:44 +01:00
Lukas
8aec460ee4 Fix production class extraction by replacing template-literal classNames with cn()
All checks were successful
CI / check (push) Successful in 1m22s
CI / build-image (push) Successful in 29s
Tailwind v4's static extractor missed classes adjacent to ${} in template
literals (e.g. `pb-8${...}`), causing missing styles in production builds.
Migrated all dynamic classNames to cn() and added a check script to prevent
regressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 12:56:15 +01:00
60 changed files with 1792 additions and 835 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,249 +1,73 @@
import {
rollAllInitiativeUseCase,
rollInitiativeUseCase,
} from "@initiative/application";
import {
type CombatantId,
type Creature,
type CreatureId,
isDomainError,
} 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";
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 [rollSkippedCount, setRollSkippedCount] = useState(0);
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) => {
addFromBestiary(result);
},
[addFromBestiary],
);
const handleCombatantStatBlock = useCallback(
(creatureId: string) => {
sidePanel.showCreature(creatureId as CreatureId);
},
[sidePanel.showCreature],
);
const handleRollInitiative = useCallback(
(id: CombatantId) => {
rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature);
},
[makeStore, getCreature],
);
const handleRollAllInitiative = useCallback(() => {
const result = rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
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-scroll to the active combatant when the turn changes
const activeRowRef = useRef<HTMLDivElement>(null);
// Auto-scroll to active combatant when turn changes
const activeIndex = encounter.activeIndex;
useEffect(() => {
activeRowRef.current?.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}, []);
if (activeIndex >= 0) {
activeRowRef.current?.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}, [activeIndex]);
return (
<div className="flex h-dvh flex-col">
<div className="relative mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-3 px-4">
{!!actionBarAnim.showTopBar && (
<div
className={`shrink-0 pt-8${actionBarAnim.topBarClass}`}
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={`w-full${actionBarAnim.risingClass}`}
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}
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) => (
@@ -252,120 +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
}
onRollInitiative={
c.creatureId ? handleRollInitiative : undefined
}
/>
))}
</div>
</div>
{/* Action Bar — fixed at bottom */}
<div
className={`shrink-0 pb-8${actionBarAnim.settlingClass}`}
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}
/>
</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}
/>
)}
<PlayerCharacterSection
ref={playerCharacterRef}
characters={playerCharacters}
onCreateCharacter={createPlayerCharacter}
onEditCharacter={editPlayerCharacter}
onDeleteCharacter={deletePlayerCharacter}
/>
{!!rolls.rollSingleSkipped && (
<Toast
message="Can't roll — bestiary source not loaded"
onDismiss={rolls.dismissRollSingleSkipped}
autoDismissMs={4000}
/>
)}
<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", () => {
@@ -75,7 +105,7 @@ describe("CombatantRow", () => {
it("active combatant gets active border styling", () => {
const { container } = renderRow({ isActive: true });
const row = container.firstElementChild;
expect(row?.className).toContain("border-l-accent");
expect(row?.className).toContain("border-active-row-border");
});
it("unconscious combatant (currentHp === 0) gets dimmed styling", () => {
@@ -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 } from "@initiative/domain";
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
import {
Check,
Eye,
@@ -6,14 +6,31 @@ import {
Import,
Library,
Minus,
Monitor,
Moon,
Plus,
Sun,
Users,
} from "lucide-react";
import React, { type RefObject, useDeferredValue, useState } from "react";
import type { SearchResult } from "../hooks/use-bestiary.js";
import React, {
type RefObject,
useCallback,
useDeferredValue,
useState,
} from "react";
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";
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
@@ -24,25 +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?: () => void;
showRollAllInitiative?: boolean;
rollAllInitiativeDisabled?: boolean;
onOpenSourceManager?: () => void;
autoFocus?: boolean;
onManagePlayers?: () => void;
}
function creatureKey(r: SearchResult): string {
@@ -77,7 +78,7 @@ function AddModeSuggestions({
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
}>) {
return (
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
<div className="card-glow absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-lg border border-border bg-card">
<button
type="button"
className="flex w-full items-center gap-1.5 border-border border-b px-3 py-2 text-left text-accent text-sm hover:bg-accent/20"
@@ -137,12 +138,14 @@ function AddModeSuggestions({
<li key={key}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${(() => {
if (isQueued) return "bg-accent/30 text-foreground";
if (i === suggestionIndex)
return "bg-accent/20 text-foreground";
return "text-foreground hover:bg-hover-neutral-bg";
})()}`}
className={cn(
"flex w-full items-center justify-between px-3 py-1.5 text-left text-foreground text-sm",
isQueued && "bg-accent/30",
!isQueued && i === suggestionIndex && "bg-accent/20",
!isQueued &&
i !== suggestionIndex &&
"hover:bg-hover-neutral-bg",
)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onClickSuggestion(result)}
onMouseEnter={() => onSetSuggestionIndex(i)}
@@ -169,7 +172,7 @@ function AddModeSuggestions({
>
<Minus className="h-3 w-3" />
</button>
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-foreground">
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground">
{queued.count}
</span>
<button
@@ -213,12 +216,26 @@ function AddModeSuggestions({
);
}
const THEME_ICONS = {
system: Monitor,
light: Sun,
dark: Moon,
} as const;
const THEME_LABELS = {
system: "Theme: System",
light: "Theme: Light",
dark: "Theme: Dark",
} as const;
function buildOverflowItems(opts: {
onManagePlayers?: () => void;
onOpenSourceManager?: () => void;
bestiaryLoaded: boolean;
onBulkImport?: () => void;
bulkImportDisabled?: boolean;
themePreference?: "system" | "light" | "dark";
onCycleTheme?: () => void;
}): OverflowMenuItem[] {
const items: OverflowMenuItem[] = [];
if (opts.onManagePlayers) {
@@ -243,27 +260,62 @@ function buildOverflowItems(opts: {
disabled: opts.bulkImportDisabled,
});
}
if (opts.onCycleTheme) {
const pref = opts.themePreference ?? "system";
const ThemeIcon = THEME_ICONS[pref];
items.push({
icon: <ThemeIcon className="h-4 w-4" />,
label: THEME_LABELS[pref],
onClick: opts.onCycleTheme,
keepOpen: true,
});
}
return items;
}
export function ActionBar({
onAddCombatant,
onAddFromBestiary,
bestiarySearch,
bestiaryLoaded,
onViewStatBlock,
onBulkImport,
bulkImportDisabled,
inputRef,
playerCharacters,
onAddFromPlayerCharacter,
onManagePlayers,
onRollAllInitiative,
showRollAllInitiative,
rollAllInitiativeDisabled,
onOpenSourceManager,
autoFocus,
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[]>([]);
@@ -300,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();
};
@@ -326,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([]);
@@ -428,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();
};
@@ -446,16 +498,37 @@ export function ActionBar({
clearCustomFields();
};
const [rollAllMenuPos, setRollAllMenuPos] = useState<{
x: number;
y: number;
} | null>(null);
const openRollAllMenu = useCallback((x: number, y: number) => {
setRollAllMenuPos({ x, y });
}, []);
const rollAllLongPress = useLongPress(
useCallback(
(e: React.TouchEvent) => {
const touch = e.touches[0];
if (touch) openRollAllMenu(touch.clientX, touch.clientY);
},
[openRollAllMenu],
),
);
const overflowItems = buildOverflowItems({
onManagePlayers,
onOpenSourceManager,
onOpenSourceManager: showSourceManager,
bestiaryLoaded,
onBulkImport,
bulkImportDisabled,
onBulkImport: showBulkImport,
bulkImportDisabled: bulkImportState.status === "loading",
themePreference,
onCycleTheme: cycleTheme,
});
return (
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
<form
onSubmit={handleAdd}
className="relative flex flex-1 items-center gap-2"
@@ -474,7 +547,7 @@ export function ActionBar({
className="pr-8"
autoFocus={autoFocus}
/>
{bestiaryLoaded && !!onViewStatBlock && (
{!!bestiaryLoaded && (
<button
type="button"
tabIndex={-1}
@@ -496,17 +569,18 @@ export function ActionBar({
</button>
)}
{browseMode && deferredSuggestions.length > 0 && (
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
<div className="card-glow absolute bottom-full z-50 mb-1 w-full rounded-lg border border-border bg-card">
<ul className="max-h-48 overflow-y-auto py-1">
{deferredSuggestions.map((result, i) => (
<li key={creatureKey(result)}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
className={cn(
"flex w-full items-center justify-between px-3 py-1.5 text-left text-sm",
i === suggestionIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
}`}
: "text-foreground hover:bg-hover-neutral-bg",
)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleBrowseSelect(result)}
onMouseEnter={() => setSuggestionIndex(i)}
@@ -534,7 +608,7 @@ export function ActionBar({
onSetSuggestionIndex={setSuggestionIndex}
onSetQueued={setQueued}
onConfirmQueued={confirmQueued}
onAddFromPlayerCharacter={onAddFromPlayerCharacter}
onAddFromPlayerCharacter={addFromPlayerCharacter}
/>
)}
</div>
@@ -570,19 +644,33 @@ export function ActionBar({
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
<Button type="submit">Add</Button>
)}
{showRollAllInitiative && !!onRollAllInitiative && (
<Button
type="button"
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-hover-action"
onClick={onRollAllInitiative}
disabled={rollAllInitiativeDisabled}
title="Roll all initiative"
aria-label="Roll all initiative"
>
<D20Icon className="h-6 w-6" />
</Button>
{!!hasCreatureCombatants && (
<>
<Button
type="button"
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-hover-action"
onClick={() => handleRollAllInitiative()}
onContextMenu={(e) => {
e.preventDefault();
openRollAllMenu(e.clientX, e.clientY);
}}
{...rollAllLongPress}
disabled={!canRollAllInitiative}
title="Roll all initiative"
aria-label="Roll all initiative"
>
<D20Icon className="h-6 w-6" />
</Button>
{!!rollAllMenuPos && (
<RollModeMenu
position={rollAllMenuPos}
onSelect={(mode) => handleRollAllInitiative(mode)}
onClose={() => setRollAllMenuPos(null)}
/>
)}
</>
)}
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
</form>

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,20 +1,27 @@
import {
type CombatantId,
type ConditionId,
type CreatureId,
deriveHpStatus,
type PlayerIcon,
type RollMode,
} from "@initiative/domain";
import { BookOpen, Brain, X } from "lucide-react";
import { Book, BookOpen, Brain, X } from "lucide-react";
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
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 { 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;
@@ -27,21 +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;
onRollInitiative?: (id: CombatantId) => void;
}
function EditableName({
@@ -79,7 +77,7 @@ function EditableName({
ref={inputRef}
type="text"
value={draft}
className="h-7 text-sm"
className="h-7 max-w-48 text-sm"
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
@@ -278,11 +276,29 @@ function InitiativeDisplay({
combatantId: CombatantId;
dimmed: boolean;
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
onRollInitiative?: (id: CombatantId) => void;
onRollInitiative?: (id: CombatantId, mode?: RollMode) => void;
}>) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(initiative?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null);
const [menuPos, setMenuPos] = useState<{
x: number;
y: number;
} | null>(null);
const openMenu = useCallback((x: number, y: number) => {
setMenuPos({ x, y });
}, []);
const longPress = useLongPress(
useCallback(
(e: React.TouchEvent) => {
const touch = e.touches[0];
if (touch) openMenu(touch.clientX, touch.clientY);
},
[openMenu],
),
);
const commit = useCallback(() => {
if (draft === "") {
@@ -324,26 +340,40 @@ function InitiativeDisplay({
);
}
// Empty + bestiary creature d20 roll button
// Empty + bestiary creature -> d20 roll button
if (initiative === undefined && onRollInitiative) {
return (
<button
type="button"
onClick={() => onRollInitiative(combatantId)}
className={cn(
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
dimmed && "opacity-50",
<>
<button
type="button"
onClick={() => onRollInitiative(combatantId)}
onContextMenu={(e) => {
e.preventDefault();
openMenu(e.clientX, e.clientY);
}}
{...longPress}
className={cn(
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
dimmed && "opacity-50",
)}
title="Roll initiative"
aria-label="Roll initiative"
>
<D20Icon className="h-7 w-7" />
</button>
{!!menuPos && (
<RollModeMenu
position={menuPos}
onSelect={(mode) => onRollInitiative(combatantId, mode)}
onClose={() => setMenuPos(null)}
/>
)}
title="Roll initiative"
aria-label="Roll initiative"
>
<D20Icon className="h-7 w-7" />
</button>
</>
);
}
// 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"
@@ -365,9 +395,13 @@ function rowBorderClass(
isActive: boolean,
isConcentrating: boolean | undefined,
): string {
if (isActive) return "border-l-2 border-l-accent bg-accent/10";
if (isConcentrating) return "border-l-2 border-l-purple-400";
return "border-l-2 border-l-transparent";
if (isActive && isConcentrating)
return "border border-l-2 border-active-row-border border-l-purple-400 bg-active-row-bg card-glow";
if (isActive)
return "border border-l-2 border-active-row-border bg-active-row-bg card-glow";
if (isConcentrating)
return "border border-l-2 border-transparent border-l-purple-400";
return "border border-l-2 border-transparent";
}
function concentrationIconClass(
@@ -383,17 +417,30 @@ export function CombatantRow({
ref,
combatant,
isActive,
onRename,
onSetInitiative,
onRemove,
onSetHp,
onAdjustHp,
onSetAc,
onToggleCondition,
onToggleConcentration,
onShowStatBlock,
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";
@@ -434,7 +481,7 @@ export function CombatantRow({
<div
ref={ref}
className={cn(
"group rounded-md pr-3 transition-colors",
"group rounded-lg pr-3 transition-colors",
rowBorderClass(isActive, combatant.isConcentrating),
isPulsing && "animate-concentration-pulse",
)}
@@ -443,7 +490,7 @@ export function CombatantRow({
{/* Concentration */}
<button
type="button"
onClick={() => onToggleConcentration(id)}
onClick={() => toggleConcentration(id)}
title="Concentrating"
aria-label="Toggle concentration"
className={cn(
@@ -459,7 +506,7 @@ export function CombatantRow({
initiative={initiative}
combatantId={id}
dimmed={dimmed}
onSetInitiative={onSetInitiative}
onSetInitiative={setInitiative}
onRollInitiative={onRollInitiative}
/>
@@ -476,9 +523,9 @@ export function CombatantRow({
onClick={onShowStatBlock}
title="View stat block"
aria-label="View stat block"
className="shrink-0 text-muted-foreground transition-colors hover:text-hover-neutral"
className="shrink-0 text-foreground transition-colors hover:text-hover-neutral"
>
<BookOpen size={14} />
{isStatBlockOpen ? <BookOpen size={16} /> : <Book size={16} />}
</button>
)}
{!!combatant.icon &&
@@ -491,7 +538,7 @@ export function CombatantRow({
];
return PcIcon ? (
<PcIcon
size={14}
size={16}
style={{ color: iconColor }}
className="shrink-0"
/>
@@ -500,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)}
/>
)}
@@ -519,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 */}
@@ -527,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 && (
@@ -541,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>
@@ -549,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

@@ -97,7 +97,7 @@ export function ConditionPicker({
<div
ref={ref}
className={cn(
"absolute left-0 z-10 w-fit overflow-y-auto rounded-md border border-border bg-background p-1 shadow-lg",
"card-glow absolute left-0 z-10 w-fit overflow-y-auto rounded-lg border border-border bg-background p-1",
flipped ? "bottom-full mb-1" : "top-full mt-1",
)}
style={maxHeight ? { maxHeight } : undefined}

View File

@@ -18,6 +18,7 @@ import {
Sparkles,
ZapOff,
} from "lucide-react";
import { cn } from "../lib/utils.js";
const ICON_MAP: Record<string, LucideIcon> = {
EyeOff,
@@ -75,7 +76,10 @@ export function ConditionTags({
type="button"
title={def.label}
aria-label={`Remove ${def.label}`}
className={`inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg ${colorClass}`}
className={cn(
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
colorClass,
)}
onClick={(e) => {
e.stopPropagation();
onRemove(condId);

View File

@@ -106,7 +106,7 @@ export function CreatePlayerModal({
return (
<dialog
ref={dialogRef}
className="m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl backdrop:bg-black/50"
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
>
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold text-foreground text-lg">

View File

@@ -87,7 +87,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
return (
<div
ref={ref}
className="fixed z-10 rounded-md border border-border bg-background p-2 shadow-lg"
className="card-glow fixed z-10 rounded-lg border border-border bg-background p-2"
style={
pos
? { top: pos.top, left: pos.left }
@@ -113,7 +113,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
<button
type="button"
disabled={!isValid}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-red-400 transition-colors hover:bg-red-950 hover:text-red-300 disabled:pointer-events-none disabled:opacity-50"
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-red-400 transition-colors hover:bg-hp-damage-hover-bg hover:text-red-300 disabled:pointer-events-none disabled:opacity-50"
onClick={() => applyDelta(-1)}
title="Apply damage"
aria-label="Apply damage"
@@ -123,7 +123,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
<button
type="button"
disabled={!isValid}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-emerald-400 transition-colors hover:bg-emerald-950 hover:text-emerald-300 disabled:pointer-events-none disabled:opacity-50"
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-emerald-400 transition-colors hover:bg-hp-heal-hover-bg hover:text-emerald-300 disabled:pointer-events-none disabled:opacity-50"
onClick={() => applyDelta(1)}
title="Apply healing"
aria-label="Apply healing"

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

@@ -55,7 +55,7 @@ export function PlayerManagement({
return (
<dialog
ref={dialogRef}
className="m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl backdrop:bg-black/50"
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
>
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold text-foreground text-lg">

View File

@@ -0,0 +1,88 @@
import type { RollMode } from "@initiative/domain";
import { ChevronsDown, ChevronsUp } from "lucide-react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
interface RollModeMenuProps {
readonly position: { x: number; y: number };
readonly onSelect: (mode: RollMode) => void;
readonly onClose: () => void;
}
export function RollModeMenu({
position,
onSelect,
onClose,
}: RollModeMenuProps) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
const vh = document.documentElement.clientHeight;
let left = position.x;
let top = position.y;
if (left + rect.width > vw) left = vw - rect.width - 8;
if (left < 8) left = 8;
if (top + rect.height > vh) top = position.y - rect.height;
if (top < 8) top = 8;
setPos({ top, left });
}, [position.x, position.y]);
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [onClose]);
return (
<div
ref={ref}
className="card-glow fixed z-50 min-w-40 rounded-lg border border-border bg-card py-1"
style={
pos
? { top: pos.top, left: pos.left }
: { visibility: "hidden" as const }
}
>
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-emerald-400 text-sm hover:bg-hover-neutral-bg"
onClick={() => {
onSelect("advantage");
onClose();
}}
>
<ChevronsUp className="h-4 w-4" />
Advantage
</button>
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-red-400 text-sm hover:bg-hover-neutral-bg"
onClick={() => {
onSelect("disadvantage");
onClose();
}}
>
<ChevronsDown className="h-4 w-4" />
Disadvantage
</button>
</div>
);
}

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,10 +1,11 @@
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";
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
import { SourceManager } from "./source-manager.js";
@@ -12,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 {
@@ -55,9 +36,10 @@ function CollapsedTab({
<button
type="button"
onClick={onToggleCollapse}
className={`flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral ${
side === "right" ? "self-start" : "self-end"
}`}
className={cn(
"flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral",
side === "right" ? "self-start" : "self-end",
)}
aria-label="Expand stat block panel"
>
<span className="writing-vertical-rl font-medium text-sm">
@@ -152,7 +134,11 @@ function DesktopPanel({
return (
<div
className={`fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel ${sideClasses} ${isCollapsed ? collapsedTranslate : "translate-x-0"}`}
className={cn(
"panel-glow fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel",
sideClasses,
isCollapsed ? collapsedTranslate : "translate-x-0",
)}
>
{isCollapsed ? (
<CollapsedTab
@@ -194,7 +180,10 @@ function MobileDrawer({
aria-label="Close stat block"
/>
<div
className={`absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-border border-l bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`}
className={cn(
"panel-glow absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-border border-l bg-card",
!isSwiping && "animate-slide-in-right",
)}
style={
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
}
@@ -219,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,
);
@@ -276,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) {
@@ -315,10 +314,7 @@ export function StatBlockPanel({
return (
<SourceFetchPrompt
sourceCode={sourceCode}
sourceDisplayName={getSourceDisplayName(sourceCode)}
fetchAndCacheSource={fetchAndCacheSource}
onSourceLoaded={handleSourceLoaded}
onUploadSource={uploadAndCacheSource}
/>
);
}

View File

@@ -30,7 +30,7 @@ function PropertyLine({
function SectionDivider() {
return (
<div className="my-2 h-px bg-gradient-to-r from-amber-800/60 via-amber-700/40 to-transparent" />
<div className="my-2 h-px bg-gradient-to-r from-stat-divider-from via-stat-divider-via to-transparent" />
);
}
@@ -54,7 +54,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
<div className="space-y-1 text-foreground">
{/* Header */}
<div>
<h2 className="font-bold text-amber-400 text-xl">{creature.name}</h2>
<h2 className="font-bold text-stat-heading text-xl">{creature.name}</h2>
<p className="text-muted-foreground text-sm italic">
{creature.size} {creature.type}, {creature.alignment}
</p>
@@ -194,7 +194,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
{creature.actions && creature.actions.length > 0 && (
<>
<SectionDivider />
<h3 className="font-bold text-amber-400 text-base">Actions</h3>
<h3 className="font-bold text-base text-stat-heading">Actions</h3>
<div className="space-y-2">
{creature.actions.map((a) => (
<div key={a.name} className="text-sm">
@@ -209,7 +209,9 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
{creature.bonusActions && creature.bonusActions.length > 0 && (
<>
<SectionDivider />
<h3 className="font-bold text-amber-400 text-base">Bonus Actions</h3>
<h3 className="font-bold text-base text-stat-heading">
Bonus Actions
</h3>
<div className="space-y-2">
{creature.bonusActions.map((a) => (
<div key={a.name} className="text-sm">
@@ -224,7 +226,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
{creature.reactions && creature.reactions.length > 0 && (
<>
<SectionDivider />
<h3 className="font-bold text-amber-400 text-base">Reactions</h3>
<h3 className="font-bold text-base text-stat-heading">Reactions</h3>
<div className="space-y-2">
{creature.reactions.map((a) => (
<div key={a.name} className="text-sm">
@@ -239,7 +241,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
{!!creature.legendaryActions && (
<>
<SectionDivider />
<h3 className="font-bold text-amber-400 text-base">
<h3 className="font-bold text-base text-stat-heading">
Legendary Actions
</h3>
<p className="text-muted-foreground text-sm italic">

View File

@@ -24,7 +24,7 @@ export function Toast({
return createPortal(
<div className="fixed bottom-4 left-4 z-50">
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg">
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
<span className="text-foreground text-sm">{message}</span>
{progress !== undefined && (
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted">

View File

@@ -1,31 +1,22 @@
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];
return (
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
<Button
variant="outline"
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="outline"
variant="ghost"
size="icon"
onClick={onAdvanceTurn}
onClick={advanceTurn}
disabled={!hasCombatants}
title="Next turn"
aria-label="Next turn"

View File

@@ -9,7 +9,7 @@ const buttonVariants = cva(
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
outline:
"border border-border bg-transparent text-foreground hover:bg-hover-neutral-bg hover:text-hover-neutral",
"border border-border bg-background/50 text-foreground hover:bg-hover-neutral-bg hover:text-hover-neutral",
ghost:
"text-foreground hover:bg-hover-neutral-bg hover:text-hover-neutral",
},

View File

@@ -7,6 +7,7 @@ export interface OverflowMenuItem {
readonly label: string;
readonly onClick: () => void;
readonly disabled?: boolean;
readonly keepOpen?: boolean;
}
interface OverflowMenuProps {
@@ -49,7 +50,7 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
<EllipsisVertical className="h-5 w-5" />
</Button>
{!!open && (
<div className="absolute right-0 bottom-full z-50 mb-1 min-w-48 rounded-md border border-border bg-card py-1 shadow-lg">
<div className="card-glow absolute right-0 bottom-full z-50 mb-1 min-w-48 rounded-lg border border-border bg-card py-1">
{items.map((item) => (
<button
key={item.label}
@@ -58,7 +59,7 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
disabled={item.disabled}
onClick={() => {
item.onClick();
setOpen(false);
if (!item.keepOpen) setOpen(false);
}}
>
{item.icon}

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

@@ -137,7 +137,9 @@ describe("useEncounter", () => {
type: "humanoid",
};
act(() => result.current.addFromBestiary(entry));
act(() => {
result.current.addFromBestiary(entry);
});
expect(result.current.hasCreatureCombatants).toBe(true);
expect(result.current.canRollAllInitiative).toBe(true);
@@ -158,7 +160,9 @@ describe("useEncounter", () => {
type: "humanoid",
};
act(() => result.current.addFromBestiary(entry));
act(() => {
result.current.addFromBestiary(entry);
});
const combatant = result.current.encounter.combatants[0];
expect(combatant.name).toBe("Goblin");
@@ -183,8 +187,12 @@ describe("useEncounter", () => {
type: "humanoid",
};
act(() => result.current.addFromBestiary(entry));
act(() => result.current.addFromBestiary(entry));
act(() => {
result.current.addFromBestiary(entry);
});
act(() => {
result.current.addFromBestiary(entry);
});
const names = result.current.encounter.combatants.map((c) => c.name);
expect(names).toContain("Goblin 1");

View File

@@ -0,0 +1,38 @@
import { useLayoutEffect, useRef, useState } from "react";
export 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),
};
}

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

@@ -17,6 +17,7 @@ import type {
BestiaryIndexEntry,
CombatantId,
ConditionId,
CreatureId,
DomainEvent,
Encounter,
PlayerCharacter,
@@ -265,7 +266,7 @@ export function useEncounter() {
}, [makeStore]);
const addFromBestiary = useCallback(
(entry: BestiaryIndexEntry) => {
(entry: BestiaryIndexEntry): CreatureId | null => {
const store = makeStore();
const existingNames = store.get().combatants.map((c) => c.name);
const { newName, renames } = resolveCreatureName(
@@ -284,7 +285,7 @@ export function useEncounter() {
// Add combatant with resolved name
const id = combatantId(`c-${++nextId.current}`);
const addResult = addCombatantUseCase(makeStore(), id, newName);
if (isDomainError(addResult)) return;
if (isDomainError(addResult)) return null;
// Set HP
const hpResult = setHpUseCase(makeStore(), id, entry.hp);
@@ -317,6 +318,8 @@ export function useEncounter() {
});
setEvents((prev) => [...prev, ...addResult]);
return cId;
},
[makeStore],
);

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

@@ -0,0 +1,32 @@
import { useCallback, useRef } from "react";
const LONG_PRESS_MS = 500;
export function useLongPress(onLongPress: (e: React.TouchEvent) => void) {
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const firedRef = useRef(false);
const onTouchStart = useCallback(
(e: React.TouchEvent) => {
firedRef.current = false;
timerRef.current = setTimeout(() => {
firedRef.current = true;
onLongPress(e);
}, LONG_PRESS_MS);
},
[onLongPress],
);
const onTouchEnd = useCallback((e: React.TouchEvent) => {
clearTimeout(timerRef.current);
if (firedRef.current) {
e.preventDefault();
}
}, []);
const onTouchMove = useCallback(() => {
clearTimeout(timerRef.current);
}, []);
return { onTouchStart, onTouchEnd, onTouchMove };
}

View File

@@ -19,6 +19,7 @@ interface SidePanelState {
interface SidePanelActions {
showCreature: (creatureId: CreatureId) => void;
updateCreature: (creatureId: CreatureId) => void;
showBulkImport: () => void;
showSourceManager: () => void;
dismissPanel: () => void;
@@ -52,6 +53,10 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
setIsRightPanelCollapsed(false);
}, []);
const updateCreature = useCallback((creatureId: CreatureId) => {
setPanelView({ mode: "creature", creatureId });
}, []);
const showBulkImport = useCallback(() => {
setPanelView({ mode: "bulk-import" });
setIsRightPanelCollapsed(false);
@@ -91,6 +96,7 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
pinnedCreatureId,
isWideDesktop,
showCreature,
updateCreature,
showBulkImport,
showSourceManager,
dismissPanel,

View File

@@ -0,0 +1,98 @@
import { useCallback, useEffect, useSyncExternalStore } from "react";
type ThemePreference = "system" | "light" | "dark";
type ResolvedTheme = "light" | "dark";
const STORAGE_KEY = "initiative:theme";
const listeners = new Set<() => void>();
let currentPreference: ThemePreference = loadPreference();
function loadPreference(): ThemePreference {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === "light" || raw === "dark" || raw === "system") return raw;
} catch {
// storage unavailable
}
return "system";
}
function savePreference(pref: ThemePreference): void {
try {
localStorage.setItem(STORAGE_KEY, pref);
} catch {
// quota exceeded or storage unavailable
}
}
function getSystemTheme(): ResolvedTheme {
if (typeof globalThis.matchMedia !== "function") return "dark";
return globalThis.matchMedia("(prefers-color-scheme: light)").matches
? "light"
: "dark";
}
function resolve(pref: ThemePreference): ResolvedTheme {
return pref === "system" ? getSystemTheme() : pref;
}
function applyTheme(resolved: ResolvedTheme): void {
document.documentElement.dataset.theme = resolved;
}
function notifyAll(): void {
for (const listener of listeners) {
listener();
}
}
// Apply on load
applyTheme(resolve(currentPreference));
// Listen for OS preference changes
if (typeof globalThis.matchMedia === "function") {
globalThis
.matchMedia("(prefers-color-scheme: light)")
.addEventListener("change", () => {
if (currentPreference === "system") {
applyTheme(resolve("system"));
notifyAll();
}
});
}
function subscribe(callback: () => void): () => void {
listeners.add(callback);
return () => listeners.delete(callback);
}
function getSnapshot(): ThemePreference {
return currentPreference;
}
const CYCLE: ThemePreference[] = ["system", "light", "dark"];
export function useTheme() {
const preference = useSyncExternalStore(subscribe, getSnapshot);
const resolved = resolve(preference);
useEffect(() => {
applyTheme(resolved);
}, [resolved]);
const setPreference = useCallback((pref: ThemePreference) => {
currentPreference = pref;
savePreference(pref);
applyTheme(resolve(pref));
notifyAll();
}, []);
const cycleTheme = useCallback(() => {
const idx = CYCLE.indexOf(currentPreference);
const next = CYCLE[(idx + 1) % CYCLE.length];
setPreference(next);
}, [setPreference]);
return { preference, resolved, setPreference, cycleTheme } as const;
}

View File

@@ -1,14 +1,14 @@
@import "tailwindcss";
@theme {
--color-background: #0f172a;
--color-background: #0e1a2e;
--color-foreground: #e2e8f0;
--color-muted: #64748b;
--color-muted: #7a8ba4;
--color-muted-foreground: #94a3b8;
--color-card: #1e293b;
--color-card: #1a2e4a;
--color-card-foreground: #e2e8f0;
--color-border: #334155;
--color-input: #334155;
--color-border: #2a5088;
--color-input: #2a5088;
--color-primary: #3b82f6;
--color-primary-foreground: #ffffff;
--color-accent: #3b82f6;
@@ -19,12 +19,47 @@
--color-hover-neutral-bg: oklch(0.623 0.214 259 / 0.15);
--color-hover-action-bg: var(--color-muted);
--color-hover-destructive-bg: transparent;
--color-stat-heading: #fbbf24;
--color-stat-divider-from: oklch(0.5 0.1 65 / 0.6);
--color-stat-divider-via: oklch(0.5 0.1 65 / 0.4);
--color-hp-damage-hover-bg: oklch(0.25 0.05 25);
--color-hp-heal-hover-bg: oklch(0.25 0.05 155);
--color-active-row-bg: oklch(0.623 0.214 259 / 0.1);
--color-active-row-border: oklch(0.623 0.214 259 / 0.4);
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
}
[data-theme="light"] {
--color-background: #eeecea;
--color-foreground: #374151;
--color-muted: #e0ddd9;
--color-muted-foreground: #6b7280;
--color-card: #f7f6f4;
--color-card-foreground: #374151;
--color-border: #ddd9d5;
--color-input: #cdc8c3;
--color-primary: #2563eb;
--color-primary-foreground: #ffffff;
--color-accent: #2563eb;
--color-destructive: #dc2626;
--color-hover-neutral: var(--color-primary);
--color-hover-action: var(--color-primary);
--color-hover-destructive: var(--color-destructive);
--color-hover-neutral-bg: oklch(0.623 0.214 259 / 0.08);
--color-hover-action-bg: var(--color-muted);
--color-hover-destructive-bg: transparent;
--color-stat-heading: #92400e;
--color-stat-divider-from: oklch(0.55 0.1 65 / 0.5);
--color-stat-divider-via: oklch(0.55 0.1 65 / 0.25);
--color-hp-damage-hover-bg: #fef2f2;
--color-hp-heal-hover-bg: #ecfdf5;
--color-active-row-bg: oklch(0.623 0.214 259 / 0.08);
--color-active-row-border: oklch(0.623 0.214 259 / 0.25);
}
@keyframes concentration-shake {
0% {
translate: 0;
@@ -169,6 +204,38 @@
concentration-glow 1200ms ease-out;
}
@utility card-glow {
background-image: radial-gradient(
ellipse at 50% 50%,
oklch(0.35 0.05 250 / 0.5) 0%,
transparent 70%
);
box-shadow:
0 0 15px -2px oklch(0.623 0.214 259 / 0.2),
inset 0 1px 0 0 oklch(0.7 0.15 259 / 0.1);
[data-theme="light"] & {
background-image: none;
box-shadow: 0 1px 3px 0 oklch(0 0 0 / 0.08);
}
}
@utility panel-glow {
background-image: linear-gradient(
to bottom,
oklch(0.35 0.05 250 / 0.4) 0%,
transparent 40%
);
box-shadow:
0 0 20px -2px oklch(0.623 0.214 259 / 0.15),
inset 0 1px 0 0 oklch(0.7 0.15 259 / 0.1);
[data-theme="light"] & {
background-image: none;
box-shadow: -1px 0 6px 0 oklch(0 0 0 / 0.1);
}
}
* {
scrollbar-color: var(--color-border) transparent;
scrollbar-width: thin;
@@ -176,6 +243,16 @@
body {
background-color: var(--color-background);
background-image: radial-gradient(
ellipse at 50% 40%,
oklch(0.26 0.055 250) 0%,
var(--color-background) 70%
);
background-attachment: fixed;
color: var(--color-foreground);
font-family: var(--font-sans);
}
[data-theme="light"] body {
background-image: none;
}

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

@@ -30,6 +30,8 @@
"jscpd": "jscpd",
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
"check:ignores": "node scripts/check-lint-ignores.mjs",
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && tsc --build && vitest run && jscpd"
"check:classnames": "node scripts/check-cn-classnames.mjs",
"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

@@ -161,6 +161,48 @@ describe("rollAllInitiativeUseCase", () => {
expect(store.saved).toBeNull();
});
it("uses higher roll with advantage", () => {
const enc = encounterWithCombatants([
{ name: "A", creatureId: "creature-a" },
]);
const store = stubEncounterStore(enc);
const creature = makeCreature("creature-a");
// Alternating rolls: 5, 15 → advantage picks 15
// Dex 14 → modifier +2, so 15 + 2 = 17
let call = 0;
const result = rollAllInitiativeUseCase(
store,
() => (++call % 2 === 1 ? 5 : 15),
(id) => (id === CREATURE_A ? creature : undefined),
"advantage",
);
expectSuccess(result);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(17);
});
it("uses lower roll with disadvantage", () => {
const enc = encounterWithCombatants([
{ name: "A", creatureId: "creature-a" },
]);
const store = stubEncounterStore(enc);
const creature = makeCreature("creature-a");
// Alternating rolls: 15, 5 → disadvantage picks 5
// Dex 14 → modifier +2, so 5 + 2 = 7
let call = 0;
const result = rollAllInitiativeUseCase(
store,
() => (++call % 2 === 1 ? 15 : 5),
(id) => (id === CREATURE_A ? creature : undefined),
"disadvantage",
);
expectSuccess(result);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(7);
});
it("saves encounter once at the end", () => {
const enc = encounterWithCombatants([
{ name: "A", creatureId: "creature-a" },

View File

@@ -61,7 +61,7 @@ describe("rollInitiativeUseCase", () => {
const result = rollInitiativeUseCase(
store,
combatantId("unknown"),
10,
[10],
() => undefined,
);
@@ -80,7 +80,7 @@ describe("rollInitiativeUseCase", () => {
const result = rollInitiativeUseCase(
store,
combatantId("Fighter"),
10,
[10],
() => undefined,
);
@@ -96,7 +96,7 @@ describe("rollInitiativeUseCase", () => {
const result = rollInitiativeUseCase(
store,
combatantId("Goblin"),
10,
[10],
() => undefined,
);
@@ -116,7 +116,7 @@ describe("rollInitiativeUseCase", () => {
const result = rollInitiativeUseCase(
store,
combatantId("Goblin"),
10,
[10],
(id) => (id === GOBLIN_ID ? creature : undefined),
);
@@ -124,6 +124,42 @@ describe("rollInitiativeUseCase", () => {
expect(requireSaved(store.saved).combatants[0].initiative).toBe(12);
});
it("uses higher roll with advantage", () => {
const creature = makeCreature();
const enc = encounterWithCreatureLink("Goblin", GOBLIN_ID);
const store = stubEncounterStore(enc);
// Dex 14 -> modifier +2, advantage picks max(5, 15) = 15, 15 + 2 = 17
const result = rollInitiativeUseCase(
store,
combatantId("Goblin"),
[5, 15],
(id) => (id === GOBLIN_ID ? creature : undefined),
"advantage",
);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(17);
});
it("uses lower roll with disadvantage", () => {
const creature = makeCreature();
const enc = encounterWithCreatureLink("Goblin", GOBLIN_ID);
const store = stubEncounterStore(enc);
// Dex 14 -> modifier +2, disadvantage picks min(5, 15) = 5, 5 + 2 = 7
const result = rollInitiativeUseCase(
store,
combatantId("Goblin"),
[5, 15],
(id) => (id === GOBLIN_ID ? creature : undefined),
"disadvantage",
);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(7);
});
it("applies initiative proficiency bonus correctly", () => {
// CR 5 -> PB 3, dex 16 -> mod +3, initiativeProficiency 1
// modifier = 3 + 1*3 = 6, roll 8 + 6 = 14
@@ -145,7 +181,7 @@ describe("rollInitiativeUseCase", () => {
const result = rollInitiativeUseCase(
store,
combatantId("Monster"),
8,
[8],
(id) => (id === GOBLIN_ID ? creature : undefined),
);

View File

@@ -5,7 +5,9 @@ import {
type DomainError,
type DomainEvent,
isDomainError,
type RollMode,
rollInitiative,
selectRoll,
setInitiative,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
@@ -19,6 +21,7 @@ export function rollAllInitiativeUseCase(
store: EncounterStore,
rollDice: () => number,
getCreature: (id: CreatureId) => Creature | undefined,
mode: RollMode = "normal",
): RollAllResult | DomainError {
let encounter = store.get();
const allEvents: DomainEvent[] = [];
@@ -39,7 +42,10 @@ export function rollAllInitiativeUseCase(
cr: creature.cr,
initiativeProficiency: creature.initiativeProficiency,
});
const value = rollInitiative(rollDice(), modifier);
const roll1 = rollDice();
const effectiveRoll =
mode === "normal" ? roll1 : selectRoll(roll1, rollDice(), mode);
const value = rollInitiative(effectiveRoll, modifier);
if (isDomainError(value)) {
return value;

View File

@@ -6,7 +6,9 @@ import {
type DomainError,
type DomainEvent,
isDomainError,
type RollMode,
rollInitiative,
selectRoll,
setInitiative,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
@@ -14,8 +16,9 @@ import type { EncounterStore } from "./ports.js";
export function rollInitiativeUseCase(
store: EncounterStore,
combatantId: CombatantId,
diceRoll: number,
diceRolls: readonly [number, ...number[]],
getCreature: (id: CreatureId) => Creature | undefined,
mode: RollMode = "normal",
): DomainEvent[] | DomainError {
const encounter = store.get();
const combatant = encounter.combatants.find((c) => c.id === combatantId);
@@ -50,7 +53,11 @@ export function rollInitiativeUseCase(
cr: creature.cr,
initiativeProficiency: creature.initiativeProficiency,
});
const value = rollInitiative(diceRoll, modifier);
const effectiveRoll =
mode === "normal"
? diceRolls[0]
: selectRoll(diceRolls[0], diceRolls[1] ?? diceRolls[0], mode);
const value = rollInitiative(effectiveRoll, modifier);
if (isDomainError(value)) {
return value;

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { rollInitiative } from "../roll-initiative.js";
import { rollInitiative, selectRoll } from "../roll-initiative.js";
import { isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
@@ -63,3 +63,31 @@ describe("rollInitiative", () => {
});
});
});
describe("selectRoll", () => {
it("normal mode returns the first roll", () => {
expect(selectRoll(8, 15, "normal")).toBe(8);
});
it("advantage returns the higher roll", () => {
expect(selectRoll(8, 15, "advantage")).toBe(15);
});
it("advantage returns the higher roll (reversed)", () => {
expect(selectRoll(15, 8, "advantage")).toBe(15);
});
it("disadvantage returns the lower roll", () => {
expect(selectRoll(8, 15, "disadvantage")).toBe(8);
});
it("disadvantage returns the lower roll (reversed)", () => {
expect(selectRoll(15, 8, "disadvantage")).toBe(8);
});
it("equal rolls return the same value for all modes", () => {
expect(selectRoll(12, 12, "normal")).toBe(12);
expect(selectRoll(12, 12, "advantage")).toBe(12);
expect(selectRoll(12, 12, "disadvantage")).toBe(12);
});
});

View File

@@ -84,7 +84,11 @@ export {
removeCombatant,
} from "./remove-combatant.js";
export { retreatTurn } from "./retreat-turn.js";
export { rollInitiative } from "./roll-initiative.js";
export {
type RollMode,
rollInitiative,
selectRoll,
} from "./roll-initiative.js";
export { type SetAcSuccess, setAc } from "./set-ac.js";
export { type SetHpSuccess, setHp } from "./set-hp.js";
export {

View File

@@ -1,5 +1,21 @@
import type { DomainError } from "./types.js";
export type RollMode = "normal" | "advantage" | "disadvantage";
/**
* Selects the effective roll from two dice values based on the roll mode.
* Advantage takes the higher, disadvantage takes the lower.
*/
export function selectRoll(
roll1: number,
roll2: number,
mode: RollMode,
): number {
if (mode === "advantage") return Math.max(roll1, roll2);
if (mode === "disadvantage") return Math.min(roll1, roll2);
return roll1;
}
/**
* Pure function that computes initiative from a resolved dice roll and modifier.
* The dice roll must be an integer in [1, 20].

View File

@@ -0,0 +1,47 @@
/**
* Ban template-literal classNames in TSX files.
*
* Tailwind v4's production content extractor does static analysis on source
* files to discover utility classes. Template literals like
* className={`foo ${bar}`}
* can cause the extractor to miss classes adjacent to `${`, leading to
* styles that work in dev (JIT) but break in production.
*
* Rule: always use cn() for dynamic class composition instead.
*/
import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";
const PATTERN = /className\s*=\s*\{`/;
function findFiles() {
return execSync("git ls-files -- '*.tsx'", { encoding: "utf-8" })
.trim()
.split("\n")
.filter(Boolean);
}
let errors = 0;
for (const file of findFiles()) {
const lines = readFileSync(file, "utf-8").split("\n");
for (let i = 0; i < lines.length; i++) {
if (PATTERN.test(lines[i])) {
console.error(
`${file}:${i + 1}: className uses template literal — use cn() instead`,
);
errors++;
}
}
}
if (errors > 0) {
console.error(
`\n${errors} template-literal className(s) found. Use cn() for dynamic classes.`,
);
process.exit(1);
} else {
console.log("No template-literal classNames found.");
}

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`,
);
}

View File

@@ -264,7 +264,7 @@ As a DM with a creature pinned, I want to collapse the right (browse) panel inde
### Edge Cases
- User pins a creature and then that creature is removed from the encounter: the pinned panel continues displaying the creature's stat block (data is already loaded).
- Active combatant changes while panel is open or collapsed: advancing turns does not auto-show or update the stat block panel. The panel only changes when the user explicitly clicks a book icon. If the panel is collapsed, it stays collapsed.
- Active combatant changes while panel is open: if the new active combatant has a creature, the panel auto-updates to show that creature's stat block. If the new active combatant has no creature, the panel remains on the previous creature. If the panel is collapsed, it stays collapsed. If the panel is closed, it stays closed.
- Viewport resized from wide to narrow while a creature is pinned: the pinned (left) panel is hidden and the pin button disappears; resizing back to wide restores the pinned panel.
- User is in bulk import mode and tries to collapse: the collapse/expand behavior applies to the bulk import panel identically.
- Panel showing a source fetch prompt: the pin button is hidden.