20 Commits
0.7.1 ... 0.7.3

Author SHA1 Message Date
Lukas
473f1eaefe Exclude agent plan files from version control
All checks were successful
CI / check (push) Successful in 46s
CI / build-image (push) Successful in 26s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:10:18 +01:00
Lukas
971e0ded49 Replace ref+tick workaround with proper state in useBestiary
Store creature map in useState instead of useRef with a dummy
tick counter. React now re-renders naturally when the map changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:02:24 +01:00
Lukas
36dcfc5076 Use useOptimistic for instant source manager cache clearing
Source rows disappear immediately when cleared instead of waiting
for the IndexedDB operation to complete. Real state syncs after.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:02:24 +01:00
Lukas
127ed01064 Add self-review checklist to CLAUDE.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:02:24 +01:00
Lukas
179c3658ad Use useDeferredValue for search dropdown rendering
Defer rendering of bestiary suggestions and player character matches
in ActionBar so the input stays responsive as the bestiary grows.
Keyboard navigation and selection logic still use the latest values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:02:23 +01:00
Lukas
01f2bb3ff1 Move derived encounter flags into useEncounter() hook
Relocate isEmpty, hasCreatureCombatants, and canRollAllInitiative
from App.tsx into useEncounter(), reducing inline derivations in
the component (Phase 5 of App decomposition plan).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:02:23 +01:00
Lukas
930301de71 Rename fold/unfold to collapse/expand across panel code
Aligns terminology with standard UI conventions. Renames props,
state, handlers, aria-labels, test descriptions, and the test file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:02:23 +01:00
Lukas
aa806d4fb9 Show bulk import toast when panel is folded
Previously the toast only showed when the panel was not in bulk-import
mode. Now it also shows when the panel is folded, since the user can't
see the in-panel progress indicator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:02:23 +01:00
Lukas
61bc274715 Extract BulkImportToasts component from App.tsx
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:02:23 +01:00
Lukas
1932e837fb Extract PlayerCharacterSection component from App.tsx
Move player character modal state (createPlayerOpen, managementOpen,
editingPlayer) into a self-contained component with an imperative ref
handle. Closing the create/edit modal now returns to management.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:02:14 +01:00
Lukas
cce87318fb Extract useSidePanelState hook from App.tsx
Move panel view state, fold/pin state, isWideDesktop media query,
and all related handlers into a dedicated hook, reducing App.tsx
by ~80 lines of state management boilerplate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:02:03 +01:00
Lukas
3ef2370a34 Replace panel mode booleans with discriminated union
Three mutually exclusive state variables (selectedCreatureId,
bulkImportMode, sourceManagerMode) replaced with a single PanelView
union type, eliminating impossible states and boolean-clearing
boilerplate in handlers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:02:03 +01:00
Lukas
c75d148d1e Fix high-severity undici vulnerability via pnpm override
Override undici to >=7.24.0 to resolve GHSA-v9p9-hfj2-hcw8
(WebSocket 64-bit length overflow). The vulnerable version was
pulled in transitively via jsdom@28.1.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:02:03 +01:00
Lukas
63e233bd8d Switch side panel to stat block when advancing turns
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 18s
Previously the import/sources view would stay open when navigating to
the next combatant. Now advancing turns clears those modes so the active
creature's stat block is shown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:03:11 +01:00
Lukas
8c62ec28f2 Rename "Bulk Import" to "Import All Sources" and remove file size mention
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:01:03 +01:00
Lukas
72195e90f6 Show toast when roll-all-initiative skips combatants without loaded sources
Previously the button silently did nothing for creatures whose bestiary
source wasn't loaded. Now it reports how many were skipped and why. Also
keeps the roll-all button visible (but disabled) when there's nothing
left to roll, and moves toasts to the bottom-left corner.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:58:25 +01:00
Lukas
6ac8e67970 Move source manager from combatant area to side panel
Source management now opens in the right side panel (like bulk import
and stat blocks) instead of rendering inline above the combatant list.
All three panel modes properly clear each other on activation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:40:28 +01:00
Lukas
a4797d5b15 Unfold side panel when triggering bulk import from overflow menu
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:33:05 +01:00
Lukas
d48e39ced4 Fix input not clearing after adding player character from suggestions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:30:24 +01:00
Lukas
b7406c4b54 Make player character color and icon optional
Clicking an already-selected color or icon in the create/edit form now
deselects it. PCs without a color use the default combatant styling;
PCs without an icon show no icon. Domain, application, persistence,
and display layers all updated to handle the optional fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:01:20 +01:00
32 changed files with 643 additions and 849 deletions

1
.gitignore vendored
View File

@@ -11,3 +11,4 @@ Thumbs.db
.idea/ .idea/
coverage/ coverage/
*.tsbuildinfo *.tsbuildinfo
docs/agents/plans/

View File

@@ -71,6 +71,14 @@ docs/agents/ RPI skill artifacts (research reports, plans)
- **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`. - **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`.
- **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. - **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
Before finishing a change, consider:
- Is this the simplest approach that solves the current problem?
- Is there duplication that hurts readability? (But don't abstract prematurely.)
- Are errors handled correctly and communicated sensibly to the user?
- Does the UI follow modern patterns and feel intuitive to interact with?
## Speckit Workflow ## Speckit Workflow
Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Specs are **living documents** that describe features, not individual changes. Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Specs are **living documents** that describe features, not individual changes.

View File

@@ -2,7 +2,12 @@ import {
rollAllInitiativeUseCase, rollAllInitiativeUseCase,
rollInitiativeUseCase, rollInitiativeUseCase,
} from "@initiative/application"; } from "@initiative/application";
import type { CombatantId, Creature, CreatureId } from "@initiative/domain"; import {
type CombatantId,
type Creature,
type CreatureId,
isDomainError,
} from "@initiative/domain";
import { import {
useCallback, useCallback,
useEffect, useEffect,
@@ -11,10 +16,12 @@ import {
useState, useState,
} from "react"; } from "react";
import { ActionBar } from "./components/action-bar"; import { ActionBar } from "./components/action-bar";
import { BulkImportToasts } from "./components/bulk-import-toasts";
import { CombatantRow } from "./components/combatant-row"; import { CombatantRow } from "./components/combatant-row";
import { CreatePlayerModal } from "./components/create-player-modal"; import {
import { PlayerManagement } from "./components/player-management"; PlayerCharacterSection,
import { SourceManager } from "./components/source-manager"; type PlayerCharacterSectionHandle,
} from "./components/player-character-section";
import { StatBlockPanel } from "./components/stat-block-panel"; import { StatBlockPanel } from "./components/stat-block-panel";
import { Toast } from "./components/toast"; import { Toast } from "./components/toast";
import { TurnNavigation } from "./components/turn-navigation"; import { TurnNavigation } from "./components/turn-navigation";
@@ -22,6 +29,7 @@ import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
import { useBulkImport } from "./hooks/use-bulk-import"; import { useBulkImport } from "./hooks/use-bulk-import";
import { useEncounter } from "./hooks/use-encounter"; import { useEncounter } from "./hooks/use-encounter";
import { usePlayerCharacters } from "./hooks/use-player-characters"; import { usePlayerCharacters } from "./hooks/use-player-characters";
import { useSidePanelState } from "./hooks/use-side-panel-state";
function rollDice(): number { function rollDice(): number {
return Math.floor(Math.random() * 20) + 1; return Math.floor(Math.random() * 20) + 1;
@@ -68,6 +76,9 @@ function useActionBarAnimation(combatantCount: number) {
export function App() { export function App() {
const { const {
encounter, encounter,
isEmpty,
hasCreatureCombatants,
canRollAllInitiative,
advanceTurn, advanceTurn,
retreatTurn, retreatTurn,
addCombatant, addCombatant,
@@ -92,12 +103,6 @@ export function App() {
deleteCharacter: deletePlayerCharacter, deleteCharacter: deletePlayerCharacter,
} = usePlayerCharacters(); } = usePlayerCharacters();
const [createPlayerOpen, setCreatePlayerOpen] = useState(false);
const [managementOpen, setManagementOpen] = useState(false);
const [editingPlayer, setEditingPlayer] = useState<
(typeof playerCharacters)[number] | undefined
>(undefined);
const { const {
search, search,
getCreature, getCreature,
@@ -109,32 +114,16 @@ export function App() {
} = useBestiary(); } = useBestiary();
const bulkImport = useBulkImport(); const bulkImport = useBulkImport();
const sidePanel = useSidePanelState();
const [selectedCreatureId, setSelectedCreatureId] = const [rollSkippedCount, setRollSkippedCount] = useState(0);
useState<CreatureId | null>(null);
const [bulkImportMode, setBulkImportMode] = useState(false);
const [sourceManagerOpen, setSourceManagerOpen] = useState(false);
const [isRightPanelFolded, setIsRightPanelFolded] = useState(false);
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
null,
);
const [isWideDesktop, setIsWideDesktop] = useState(
() => window.matchMedia("(min-width: 1280px)").matches,
);
useEffect(() => { const selectedCreature: Creature | null = sidePanel.selectedCreatureId
const mq = window.matchMedia("(min-width: 1280px)"); ? (getCreature(sidePanel.selectedCreatureId) ?? null)
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const selectedCreature: Creature | null = selectedCreatureId
? (getCreature(selectedCreatureId) ?? null)
: null; : null;
const pinnedCreature: Creature | null = pinnedCreatureId const pinnedCreature: Creature | null = sidePanel.pinnedCreatureId
? (getCreature(pinnedCreatureId) ?? null) ? (getCreature(sidePanel.pinnedCreatureId) ?? null)
: null; : null;
const handleAddFromBestiary = useCallback( const handleAddFromBestiary = useCallback(
@@ -144,10 +133,12 @@ export function App() {
[addFromBestiary], [addFromBestiary],
); );
const handleCombatantStatBlock = useCallback((creatureId: string) => { const handleCombatantStatBlock = useCallback(
setSelectedCreatureId(creatureId as CreatureId); (creatureId: string) => {
setIsRightPanelFolded(false); sidePanel.showCreature(creatureId as CreatureId);
}, []); },
[sidePanel.showCreature],
);
const handleRollInitiative = useCallback( const handleRollInitiative = useCallback(
(id: CombatantId) => { (id: CombatantId) => {
@@ -157,23 +148,23 @@ export function App() {
); );
const handleRollAllInitiative = useCallback(() => { const handleRollAllInitiative = useCallback(() => {
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature); const result = rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
if (!isDomainError(result) && result.skippedNoSource > 0) {
setRollSkippedCount(result.skippedNoSource);
}
}, [makeStore, getCreature]); }, [makeStore, getCreature]);
const handleViewStatBlock = useCallback((result: SearchResult) => { const handleViewStatBlock = useCallback(
const slug = result.name (result: SearchResult) => {
.toLowerCase() const slug = result.name
.replace(/[^a-z0-9]+/g, "-") .toLowerCase()
.replace(/(^-|-$)/g, ""); .replace(/[^a-z0-9]+/g, "-")
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId; .replace(/(^-|-$)/g, "");
setSelectedCreatureId(cId); const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
setIsRightPanelFolded(false); sidePanel.showCreature(cId);
}, []); },
[sidePanel.showCreature],
const handleBulkImport = useCallback(() => { );
setBulkImportMode(true);
setSelectedCreatureId(null);
}, []);
const handleStartBulkImport = useCallback( const handleStartBulkImport = useCallback(
(baseUrl: string) => { (baseUrl: string) => {
@@ -188,32 +179,12 @@ export function App() {
); );
const handleBulkImportDone = useCallback(() => { const handleBulkImportDone = useCallback(() => {
setBulkImportMode(false); sidePanel.dismissPanel();
bulkImport.reset(); bulkImport.reset();
}, [bulkImport.reset]); }, [sidePanel.dismissPanel, bulkImport.reset]);
const handleDismissBrowsePanel = useCallback(() => {
setSelectedCreatureId(null);
setBulkImportMode(false);
}, []);
const handleToggleFold = useCallback(() => {
setIsRightPanelFolded((f) => !f);
}, []);
const handlePin = useCallback(() => {
if (selectedCreatureId) {
setPinnedCreatureId((prev) =>
prev === selectedCreatureId ? null : selectedCreatureId,
);
}
}, [selectedCreatureId]);
const handleUnpin = useCallback(() => {
setPinnedCreatureId(null);
}, []);
const actionBarInputRef = useRef<HTMLInputElement>(null); const actionBarInputRef = useRef<HTMLInputElement>(null);
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
const actionBarAnim = useActionBarAnimation(encounter.combatants.length); const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
// Auto-scroll to the active combatant when the turn changes // Auto-scroll to the active combatant when the turn changes
@@ -235,13 +206,13 @@ export function App() {
if (!window.matchMedia("(min-width: 1024px)").matches) return; if (!window.matchMedia("(min-width: 1024px)").matches) return;
const active = encounter.combatants[encounter.activeIndex]; const active = encounter.combatants[encounter.activeIndex];
if (!active?.creatureId || !isLoaded) return; if (!active?.creatureId || !isLoaded) return;
setSelectedCreatureId(active.creatureId as CreatureId); sidePanel.showCreature(active.creatureId as CreatureId);
}, [encounter.activeIndex, encounter.combatants, isLoaded]); }, [
encounter.activeIndex,
const isEmpty = encounter.combatants.length === 0; encounter.combatants,
const showRollAllInitiative = encounter.combatants.some( isLoaded,
(c) => c.creatureId != null && c.initiative == null, sidePanel.showCreature,
); ]);
return ( return (
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
@@ -273,27 +244,24 @@ export function App() {
bestiarySearch={search} bestiarySearch={search}
bestiaryLoaded={isLoaded} bestiaryLoaded={isLoaded}
onViewStatBlock={handleViewStatBlock} onViewStatBlock={handleViewStatBlock}
onBulkImport={handleBulkImport} onBulkImport={sidePanel.showBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"} bulkImportDisabled={bulkImport.state.status === "loading"}
inputRef={actionBarInputRef} inputRef={actionBarInputRef}
playerCharacters={playerCharacters} playerCharacters={playerCharacters}
onAddFromPlayerCharacter={addFromPlayerCharacter} onAddFromPlayerCharacter={addFromPlayerCharacter}
onManagePlayers={() => setManagementOpen(true)} onManagePlayers={() =>
playerCharacterRef.current?.openManagement()
}
onRollAllInitiative={handleRollAllInitiative} onRollAllInitiative={handleRollAllInitiative}
showRollAllInitiative={showRollAllInitiative} showRollAllInitiative={hasCreatureCombatants}
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)} rollAllInitiativeDisabled={!canRollAllInitiative}
onOpenSourceManager={sidePanel.showSourceManager}
autoFocus autoFocus
/> />
</div> </div>
</div> </div>
) : ( ) : (
<> <>
{sourceManagerOpen && (
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
<SourceManager onCacheCleared={refreshCache} />
</div>
)}
{/* Scrollable area — combatant list */} {/* Scrollable area — combatant list */}
<div className="flex-1 overflow-y-auto min-h-0"> <div className="flex-1 overflow-y-auto min-h-0">
<div className="flex flex-col px-2 py-2"> <div className="flex flex-col px-2 py-2">
@@ -335,15 +303,18 @@ export function App() {
bestiarySearch={search} bestiarySearch={search}
bestiaryLoaded={isLoaded} bestiaryLoaded={isLoaded}
onViewStatBlock={handleViewStatBlock} onViewStatBlock={handleViewStatBlock}
onBulkImport={handleBulkImport} onBulkImport={sidePanel.showBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"} bulkImportDisabled={bulkImport.state.status === "loading"}
inputRef={actionBarInputRef} inputRef={actionBarInputRef}
playerCharacters={playerCharacters} playerCharacters={playerCharacters}
onAddFromPlayerCharacter={addFromPlayerCharacter} onAddFromPlayerCharacter={addFromPlayerCharacter}
onManagePlayers={() => setManagementOpen(true)} onManagePlayers={() =>
playerCharacterRef.current?.openManagement()
}
onRollAllInitiative={handleRollAllInitiative} onRollAllInitiative={handleRollAllInitiative}
showRollAllInitiative={showRollAllInitiative} showRollAllInitiative={hasCreatureCombatants}
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)} rollAllInitiativeDisabled={!canRollAllInitiative}
onOpenSourceManager={sidePanel.showSourceManager}
/> />
</div> </div>
</> </>
@@ -351,19 +322,19 @@ export function App() {
</div> </div>
{/* Pinned Stat Block Panel (left) */} {/* Pinned Stat Block Panel (left) */}
{pinnedCreatureId && isWideDesktop && ( {sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
<StatBlockPanel <StatBlockPanel
creatureId={pinnedCreatureId} creatureId={sidePanel.pinnedCreatureId}
creature={pinnedCreature} creature={pinnedCreature}
isSourceCached={isSourceCached} isSourceCached={isSourceCached}
fetchAndCacheSource={fetchAndCacheSource} fetchAndCacheSource={fetchAndCacheSource}
uploadAndCacheSource={uploadAndCacheSource} uploadAndCacheSource={uploadAndCacheSource}
refreshCache={refreshCache} refreshCache={refreshCache}
panelRole="pinned" panelRole="pinned"
isFolded={false} isCollapsed={false}
onToggleFold={() => {}} onToggleCollapse={() => {}}
onPin={() => {}} onPin={() => {}}
onUnpin={handleUnpin} onUnpin={sidePanel.unpin}
showPinButton={false} showPinButton={false}
side="left" side="left"
onDismiss={() => {}} onDismiss={() => {}}
@@ -372,90 +343,47 @@ export function App() {
{/* Browse Stat Block Panel (right) */} {/* Browse Stat Block Panel (right) */}
<StatBlockPanel <StatBlockPanel
creatureId={selectedCreatureId} creatureId={sidePanel.selectedCreatureId}
creature={selectedCreature} creature={selectedCreature}
isSourceCached={isSourceCached} isSourceCached={isSourceCached}
fetchAndCacheSource={fetchAndCacheSource} fetchAndCacheSource={fetchAndCacheSource}
uploadAndCacheSource={uploadAndCacheSource} uploadAndCacheSource={uploadAndCacheSource}
refreshCache={refreshCache} refreshCache={refreshCache}
panelRole="browse" panelRole="browse"
isFolded={isRightPanelFolded} isCollapsed={sidePanel.isRightPanelCollapsed}
onToggleFold={handleToggleFold} onToggleCollapse={sidePanel.toggleCollapse}
onPin={handlePin} onPin={sidePanel.togglePin}
onUnpin={() => {}} onUnpin={() => {}}
showPinButton={isWideDesktop && !!selectedCreature} showPinButton={sidePanel.isWideDesktop && !!selectedCreature}
side="right" side="right"
onDismiss={handleDismissBrowsePanel} onDismiss={sidePanel.dismissPanel}
bulkImportMode={bulkImportMode} bulkImportMode={sidePanel.bulkImportMode}
bulkImportState={bulkImport.state} bulkImportState={bulkImport.state}
onStartBulkImport={handleStartBulkImport} onStartBulkImport={handleStartBulkImport}
onBulkImportDone={handleBulkImportDone} onBulkImportDone={handleBulkImportDone}
sourceManagerMode={sidePanel.sourceManagerMode}
/> />
{/* Toast for bulk import progress when panel is closed */} <BulkImportToasts
{bulkImport.state.status === "loading" && !bulkImportMode && ( state={bulkImport.state}
<Toast visible={!sidePanel.bulkImportMode || sidePanel.isRightPanelCollapsed}
message={`Loading sources... ${bulkImport.state.completed + bulkImport.state.failed}/${bulkImport.state.total}`} onReset={bulkImport.reset}
progress={
bulkImport.state.total > 0
? (bulkImport.state.completed + bulkImport.state.failed) /
bulkImport.state.total
: 0
}
onDismiss={() => {}}
/>
)}
{bulkImport.state.status === "complete" && !bulkImportMode && (
<Toast
message="All sources loaded"
onDismiss={bulkImport.reset}
autoDismissMs={3000}
/>
)}
{bulkImport.state.status === "partial-failure" && !bulkImportMode && (
<Toast
message={`Loaded ${bulkImport.state.completed}/${bulkImport.state.total} sources (${bulkImport.state.failed} failed)`}
onDismiss={bulkImport.reset}
/>
)}
<CreatePlayerModal
open={createPlayerOpen}
onClose={() => {
setCreatePlayerOpen(false);
setEditingPlayer(undefined);
}}
onSave={(name, ac, maxHp, color, icon) => {
if (editingPlayer) {
editPlayerCharacter?.(editingPlayer.id, {
name,
ac,
maxHp,
color,
icon,
});
} else {
createPlayerCharacter(name, ac, maxHp, color, icon);
}
}}
playerCharacter={editingPlayer}
/> />
<PlayerManagement {rollSkippedCount > 0 && (
open={managementOpen} <Toast
onClose={() => setManagementOpen(false)} message={`${rollSkippedCount} skipped — bestiary source not loaded`}
onDismiss={() => setRollSkippedCount(0)}
autoDismissMs={4000}
/>
)}
<PlayerCharacterSection
ref={playerCharacterRef}
characters={playerCharacters} characters={playerCharacters}
onEdit={(pc) => { onCreateCharacter={createPlayerCharacter}
setEditingPlayer(pc); onEditCharacter={editPlayerCharacter}
setCreatePlayerOpen(true); onDeleteCharacter={deletePlayerCharacter}
setManagementOpen(false);
}}
onDelete={(id) => deletePlayerCharacter?.(id)}
onCreate={() => {
setEditingPlayer(undefined);
setCreatePlayerOpen(true);
setManagementOpen(false);
}}
/> />
</div> </div>
); );

View File

@@ -45,8 +45,8 @@ interface PanelProps {
creatureId?: CreatureId | null; creatureId?: CreatureId | null;
creature?: Creature | null; creature?: Creature | null;
panelRole?: "browse" | "pinned"; panelRole?: "browse" | "pinned";
isFolded?: boolean; isCollapsed?: boolean;
onToggleFold?: () => void; onToggleCollapse?: () => void;
onPin?: () => void; onPin?: () => void;
onUnpin?: () => void; onUnpin?: () => void;
showPinButton?: boolean; showPinButton?: boolean;
@@ -64,8 +64,8 @@ function renderPanel(overrides: PanelProps = {}) {
uploadAndCacheSource: vi.fn(), uploadAndCacheSource: vi.fn(),
refreshCache: vi.fn(), refreshCache: vi.fn(),
panelRole: "browse" as const, panelRole: "browse" as const,
isFolded: false, isCollapsed: false,
onToggleFold: vi.fn(), onToggleCollapse: vi.fn(),
onPin: vi.fn(), onPin: vi.fn(),
onUnpin: vi.fn(), onUnpin: vi.fn(),
showPinButton: false, showPinButton: false,
@@ -78,18 +78,18 @@ function renderPanel(overrides: PanelProps = {}) {
return props; return props;
} }
describe("Stat Block Panel Fold/Unfold and Pin", () => { describe("Stat Block Panel Collapse/Expand and Pin", () => {
beforeEach(() => { beforeEach(() => {
mockMatchMedia(true); // desktop by default mockMatchMedia(true); // desktop by default
}); });
afterEach(cleanup); afterEach(cleanup);
describe("US1: Fold and Unfold", () => { describe("US1: Collapse and Expand", () => {
it("shows fold button instead of close button on desktop", () => { it("shows collapse button instead of close button on desktop", () => {
renderPanel(); renderPanel();
expect( expect(
screen.getByRole("button", { name: "Fold stat block panel" }), screen.getByRole("button", { name: "Collapse stat block panel" }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.queryByRole("button", { name: /close/i }), screen.queryByRole("button", { name: /close/i }),
@@ -101,42 +101,42 @@ describe("Stat Block Panel Fold/Unfold and Pin", () => {
expect(screen.queryByText("Stat Block")).not.toBeInTheDocument(); expect(screen.queryByText("Stat Block")).not.toBeInTheDocument();
}); });
it("renders folded tab with creature name when isFolded is true", () => { it("renders collapsed tab with creature name when isCollapsed is true", () => {
renderPanel({ isFolded: true }); renderPanel({ isCollapsed: true });
expect(screen.getByText("Goblin")).toBeInTheDocument(); expect(screen.getByText("Goblin")).toBeInTheDocument();
expect( expect(
screen.getByRole("button", { name: "Unfold stat block panel" }), screen.getByRole("button", { name: "Expand stat block panel" }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("calls onToggleFold when fold button is clicked", () => { it("calls onToggleCollapse when collapse button is clicked", () => {
const props = renderPanel(); const props = renderPanel();
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "Fold stat block panel" }), screen.getByRole("button", { name: "Collapse stat block panel" }),
); );
expect(props.onToggleFold).toHaveBeenCalledTimes(1); expect(props.onToggleCollapse).toHaveBeenCalledTimes(1);
}); });
it("calls onToggleFold when folded tab is clicked", () => { it("calls onToggleCollapse when collapsed tab is clicked", () => {
const props = renderPanel({ isFolded: true }); const props = renderPanel({ isCollapsed: true });
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "Unfold stat block panel" }), screen.getByRole("button", { name: "Expand stat block panel" }),
); );
expect(props.onToggleFold).toHaveBeenCalledTimes(1); expect(props.onToggleCollapse).toHaveBeenCalledTimes(1);
}); });
it("applies translate-x class when folded (right side)", () => { it("applies translate-x class when collapsed (right side)", () => {
renderPanel({ isFolded: true, side: "right" }); renderPanel({ isCollapsed: true, side: "right" });
const panel = screen const panel = screen
.getByRole("button", { name: "Unfold stat block panel" }) .getByRole("button", { name: "Expand stat block panel" })
.closest("div"); .closest("div");
expect(panel?.className).toContain("translate-x-[calc(100%-40px)]"); expect(panel?.className).toContain("translate-x-[calc(100%-40px)]");
}); });
it("applies translate-x-0 when expanded", () => { it("applies translate-x-0 when expanded", () => {
renderPanel({ isFolded: false }); renderPanel({ isCollapsed: false });
const foldBtn = screen.getByRole("button", { const foldBtn = screen.getByRole("button", {
name: "Fold stat block panel", name: "Collapse stat block panel",
}); });
const panel = foldBtn.closest("div.fixed") as HTMLElement; const panel = foldBtn.closest("div.fixed") as HTMLElement;
expect(panel?.className).toContain("translate-x-0"); expect(panel?.className).toContain("translate-x-0");
@@ -148,12 +148,12 @@ describe("Stat Block Panel Fold/Unfold and Pin", () => {
mockMatchMedia(false); // mobile mockMatchMedia(false); // mobile
}); });
it("shows fold button instead of X close button on mobile drawer", () => { it("shows collapse button instead of X close button on mobile drawer", () => {
renderPanel(); renderPanel();
expect( expect(
screen.getByRole("button", { name: "Fold stat block panel" }), screen.getByRole("button", { name: "Collapse stat block panel" }),
).toBeInTheDocument(); ).toBeInTheDocument();
// No X close icon button — only backdrop dismiss and fold toggle // No X close icon button — only backdrop dismiss and collapse toggle
const buttons = screen.getAllByRole("button"); const buttons = screen.getAllByRole("button");
const buttonLabels = buttons.map((b) => b.getAttribute("aria-label")); const buttonLabels = buttons.map((b) => b.getAttribute("aria-label"));
expect(buttonLabels).not.toContain("Close"); expect(buttonLabels).not.toContain("Close");
@@ -175,8 +175,8 @@ describe("Stat Block Panel Fold/Unfold and Pin", () => {
uploadAndCacheSource={vi.fn()} uploadAndCacheSource={vi.fn()}
refreshCache={vi.fn()} refreshCache={vi.fn()}
panelRole="pinned" panelRole="pinned"
isFolded={false} isCollapsed={false}
onToggleFold={vi.fn()} onToggleCollapse={vi.fn()}
onPin={vi.fn()} onPin={vi.fn()}
onUnpin={vi.fn()} onUnpin={vi.fn()}
showPinButton={false} showPinButton={false}
@@ -235,7 +235,7 @@ describe("Stat Block Panel Fold/Unfold and Pin", () => {
it("positions browse panel on the right side", () => { it("positions browse panel on the right side", () => {
renderPanel({ panelRole: "browse", side: "right" }); renderPanel({ panelRole: "browse", side: "right" });
const foldBtn = screen.getByRole("button", { const foldBtn = screen.getByRole("button", {
name: "Fold stat block panel", name: "Collapse stat block panel",
}); });
const panel = foldBtn.closest("div.fixed") as HTMLElement; const panel = foldBtn.closest("div.fixed") as HTMLElement;
expect(panel?.className).toContain("right-0"); expect(panel?.className).toContain("right-0");
@@ -243,16 +243,16 @@ describe("Stat Block Panel Fold/Unfold and Pin", () => {
}); });
}); });
describe("US3: Fold independence with pinned panel", () => { describe("US3: Collapse independence with pinned panel", () => {
it("pinned panel has no fold button", () => { it("pinned panel has no collapse button", () => {
renderPanel({ panelRole: "pinned", side: "left" }); renderPanel({ panelRole: "pinned", side: "left" });
expect( expect(
screen.queryByRole("button", { name: /fold/i }), screen.queryByRole("button", { name: /collapse/i }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("pinned panel is always expanded (no translate offset)", () => { it("pinned panel is always expanded (no translate offset)", () => {
renderPanel({ panelRole: "pinned", side: "left", isFolded: false }); renderPanel({ panelRole: "pinned", side: "left", isCollapsed: false });
const unpinBtn = screen.getByRole("button", { const unpinBtn = screen.getByRole("button", {
name: "Unpin creature", name: "Unpin creature",
}); });

View File

@@ -9,7 +9,12 @@ import {
Plus, Plus,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { type FormEvent, type RefObject, useState } from "react"; import {
type FormEvent,
type RefObject,
useDeferredValue,
useState,
} from "react";
import type { SearchResult } from "../hooks/use-bestiary.js"; import type { SearchResult } from "../hooks/use-bestiary.js";
import { cn } from "../lib/utils.js"; import { cn } from "../lib/utils.js";
import { D20Icon } from "./d20-icon.js"; import { D20Icon } from "./d20-icon.js";
@@ -40,6 +45,7 @@ interface ActionBarProps {
onManagePlayers?: () => void; onManagePlayers?: () => void;
onRollAllInitiative?: () => void; onRollAllInitiative?: () => void;
showRollAllInitiative?: boolean; showRollAllInitiative?: boolean;
rollAllInitiativeDisabled?: boolean;
onOpenSourceManager?: () => void; onOpenSourceManager?: () => void;
autoFocus?: boolean; autoFocus?: boolean;
} }
@@ -60,6 +66,7 @@ function AddModeSuggestions({
onSetQueued, onSetQueued,
onConfirmQueued, onConfirmQueued,
onAddFromPlayerCharacter, onAddFromPlayerCharacter,
onClear,
}: { }: {
nameInput: string; nameInput: string;
suggestions: SearchResult[]; suggestions: SearchResult[];
@@ -67,6 +74,7 @@ function AddModeSuggestions({
suggestionIndex: number; suggestionIndex: number;
queued: QueuedCreature | null; queued: QueuedCreature | null;
onDismiss: () => void; onDismiss: () => void;
onClear: () => void;
onClickSuggestion: (result: SearchResult) => void; onClickSuggestion: (result: SearchResult) => void;
onSetSuggestionIndex: (i: number) => void; onSetSuggestionIndex: (i: number) => void;
onSetQueued: (q: QueuedCreature | null) => void; onSetQueued: (q: QueuedCreature | null) => void;
@@ -95,9 +103,12 @@ function AddModeSuggestions({
</div> </div>
<ul> <ul>
{pcMatches.map((pc) => { {pcMatches.map((pc) => {
const PcIcon = PLAYER_ICON_MAP[pc.icon as PlayerIcon]; const PcIcon = pc.icon
const pcColor = ? PLAYER_ICON_MAP[pc.icon as PlayerIcon]
PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX]; : undefined;
const pcColor = pc.color
? PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX]
: undefined;
return ( return (
<li key={pc.id}> <li key={pc.id}>
<button <button
@@ -106,7 +117,7 @@ function AddModeSuggestions({
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => { onClick={() => {
onAddFromPlayerCharacter?.(pc); onAddFromPlayerCharacter?.(pc);
onDismiss(); onClear();
}} }}
> >
{PcIcon && ( {PcIcon && (
@@ -235,7 +246,7 @@ function buildOverflowItems(opts: {
if (opts.bestiaryLoaded && opts.onBulkImport) { if (opts.bestiaryLoaded && opts.onBulkImport) {
items.push({ items.push({
icon: <Import className="h-4 w-4" />, icon: <Import className="h-4 w-4" />,
label: "Bulk Import", label: "Import All Sources",
onClick: opts.onBulkImport, onClick: opts.onBulkImport,
disabled: opts.bulkImportDisabled, disabled: opts.bulkImportDisabled,
}); });
@@ -257,12 +268,15 @@ export function ActionBar({
onManagePlayers, onManagePlayers,
onRollAllInitiative, onRollAllInitiative,
showRollAllInitiative, showRollAllInitiative,
rollAllInitiativeDisabled,
onOpenSourceManager, onOpenSourceManager,
autoFocus, autoFocus,
}: ActionBarProps) { }: ActionBarProps) {
const [nameInput, setNameInput] = useState(""); const [nameInput, setNameInput] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]); const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]); const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
const deferredSuggestions = useDeferredValue(suggestions);
const deferredPcMatches = useDeferredValue(pcMatches);
const [suggestionIndex, setSuggestionIndex] = useState(-1); const [suggestionIndex, setSuggestionIndex] = useState(-1);
const [queued, setQueued] = useState<QueuedCreature | null>(null); const [queued, setQueued] = useState<QueuedCreature | null>(null);
const [customInit, setCustomInit] = useState(""); const [customInit, setCustomInit] = useState("");
@@ -387,7 +401,8 @@ export function ActionBar({
} }
}; };
const hasSuggestions = suggestions.length > 0 || pcMatches.length > 0; const hasSuggestions =
deferredSuggestions.length > 0 || deferredPcMatches.length > 0;
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (!hasSuggestions) return; if (!hasSuggestions) return;
@@ -488,10 +503,10 @@ export function ActionBar({
)} )}
</button> </button>
)} )}
{browseMode && suggestions.length > 0 && ( {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="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
<ul className="max-h-48 overflow-y-auto py-1"> <ul className="max-h-48 overflow-y-auto py-1">
{suggestions.map((result, i) => ( {deferredSuggestions.map((result, i) => (
<li key={creatureKey(result)}> <li key={creatureKey(result)}>
<button <button
type="button" type="button"
@@ -517,11 +532,12 @@ export function ActionBar({
{!browseMode && hasSuggestions && ( {!browseMode && hasSuggestions && (
<AddModeSuggestions <AddModeSuggestions
nameInput={nameInput} nameInput={nameInput}
suggestions={suggestions} suggestions={deferredSuggestions}
pcMatches={pcMatches} pcMatches={deferredPcMatches}
suggestionIndex={suggestionIndex} suggestionIndex={suggestionIndex}
queued={queued} queued={queued}
onDismiss={dismissSuggestions} onDismiss={dismissSuggestions}
onClear={clearInput}
onClickSuggestion={handleClickSuggestion} onClickSuggestion={handleClickSuggestion}
onSetSuggestionIndex={setSuggestionIndex} onSetSuggestionIndex={setSuggestionIndex}
onSetQueued={setQueued} onSetQueued={setQueued}
@@ -569,6 +585,7 @@ export function ActionBar({
variant="ghost" variant="ghost"
className="text-muted-foreground hover:text-hover-action" className="text-muted-foreground hover:text-hover-action"
onClick={onRollAllInitiative} onClick={onRollAllInitiative}
disabled={rollAllInitiativeDisabled}
title="Roll all initiative" title="Roll all initiative"
aria-label="Roll all initiative" aria-label="Roll all initiative"
> >

View File

@@ -75,11 +75,10 @@ export function BulkImportPrompt({
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div> <div>
<h3 className="text-sm font-semibold text-foreground"> <h3 className="text-sm font-semibold text-foreground">
Bulk Import Sources Import All Sources
</h3> </h3>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Load stat block data for all {totalSources} sources at once. This will Load stat block data for all {totalSources} sources at once.
download approximately 12.5 MB of data.
</p> </p>
</div> </div>

View File

@@ -0,0 +1,49 @@
import type { BulkImportState } from "../hooks/use-bulk-import.js";
import { Toast } from "./toast.js";
interface BulkImportToastsProps {
state: BulkImportState;
visible: boolean;
onReset: () => void;
}
export function BulkImportToasts({
state,
visible,
onReset,
}: BulkImportToastsProps) {
if (!visible) return null;
if (state.status === "loading") {
return (
<Toast
message={`Loading sources... ${state.completed + state.failed}/${state.total}`}
progress={
state.total > 0 ? (state.completed + state.failed) / state.total : 0
}
onDismiss={() => {}}
/>
);
}
if (state.status === "complete") {
return (
<Toast
message="All sources loaded"
onDismiss={onReset}
autoDismissMs={3000}
/>
);
}
if (state.status === "partial-failure") {
return (
<Toast
message={`Loaded ${state.completed}/${state.total} sources (${state.failed} failed)`}
onDismiss={onReset}
/>
);
}
return null;
}

View File

@@ -16,7 +16,7 @@ export function ColorPalette({ value, onChange }: ColorPaletteProps) {
<button <button
key={color} key={color}
type="button" type="button"
onClick={() => onChange(color)} onClick={() => onChange(value === color ? "" : color)}
className={cn( className={cn(
"h-8 w-8 rounded-full transition-all", "h-8 w-8 rounded-full transition-all",
value === color value === color

View File

@@ -13,8 +13,8 @@ interface CreatePlayerModalProps {
name: string, name: string,
ac: number, ac: number,
maxHp: number, maxHp: number,
color: string, color: string | undefined,
icon: string, icon: string | undefined,
) => void; ) => void;
playerCharacter?: PlayerCharacter; playerCharacter?: PlayerCharacter;
} }
@@ -40,14 +40,14 @@ export function CreatePlayerModal({
setName(playerCharacter.name); setName(playerCharacter.name);
setAc(String(playerCharacter.ac)); setAc(String(playerCharacter.ac));
setMaxHp(String(playerCharacter.maxHp)); setMaxHp(String(playerCharacter.maxHp));
setColor(playerCharacter.color); setColor(playerCharacter.color ?? "");
setIcon(playerCharacter.icon); setIcon(playerCharacter.icon ?? "");
} else { } else {
setName(""); setName("");
setAc("10"); setAc("10");
setMaxHp("10"); setMaxHp("10");
setColor("blue"); setColor("");
setIcon("sword"); setIcon("");
} }
setError(""); setError("");
} }
@@ -81,7 +81,7 @@ export function CreatePlayerModal({
setError("Max HP must be at least 1"); setError("Max HP must be at least 1");
return; return;
} }
onSave(trimmed, acNum, hpNum, color, icon); onSave(trimmed, acNum, hpNum, color || undefined, icon || undefined);
onClose(); onClose();
}; };

View File

@@ -19,7 +19,7 @@ export function IconGrid({ value, onChange }: IconGridProps) {
<button <button
key={iconId} key={iconId}
type="button" type="button"
onClick={() => onChange(iconId)} onClick={() => onChange(value === iconId ? "" : iconId)}
className={cn( className={cn(
"flex h-9 w-9 items-center justify-center rounded-md transition-all", "flex h-9 w-9 items-center justify-center rounded-md transition-all",
value === iconId value === iconId

View File

@@ -0,0 +1,91 @@
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
import { forwardRef, useImperativeHandle, useState } from "react";
import { CreatePlayerModal } from "./create-player-modal.js";
import { PlayerManagement } from "./player-management.js";
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 = forwardRef<
PlayerCharacterSectionHandle,
PlayerCharacterSectionProps
>(function PlayerCharacterSection(
{ characters, onCreateCharacter, onEditCharacter, onDeleteCharacter },
ref,
) {
const [managementOpen, setManagementOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [editingPlayer, setEditingPlayer] = useState<
PlayerCharacter | undefined
>();
useImperativeHandle(ref, () => ({
openManagement: () => setManagementOpen(true),
}));
return (
<>
<CreatePlayerModal
open={createOpen}
onClose={() => {
setCreateOpen(false);
setEditingPlayer(undefined);
setManagementOpen(true);
}}
onSave={(name, ac, maxHp, color, icon) => {
if (editingPlayer) {
onEditCharacter(editingPlayer.id, {
name,
ac,
maxHp,
color: color ?? null,
icon: icon ?? null,
});
} else {
onCreateCharacter(name, ac, maxHp, color, icon);
}
}}
playerCharacter={editingPlayer}
/>
<PlayerManagement
open={managementOpen}
onClose={() => setManagementOpen(false)}
characters={characters}
onEdit={(pc) => {
setEditingPlayer(pc);
setCreateOpen(true);
setManagementOpen(false);
}}
onDelete={(id) => onDeleteCharacter(id)}
onCreate={() => {
setEditingPlayer(undefined);
setCreateOpen(true);
setManagementOpen(false);
}}
/>
</>
);
});

View File

@@ -1,8 +1,4 @@
import type { import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
PlayerCharacter,
PlayerCharacterId,
PlayerIcon,
} from "@initiative/domain";
import { Pencil, Plus, Trash2, X } from "lucide-react"; import { Pencil, Plus, Trash2, X } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map"; import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
@@ -73,9 +69,8 @@ export function PlayerManagement({
) : ( ) : (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{characters.map((pc) => { {characters.map((pc) => {
const Icon = PLAYER_ICON_MAP[pc.icon as PlayerIcon]; const Icon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
const color = const color = pc.color ? PLAYER_COLOR_HEX[pc.color] : undefined;
PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX];
return ( return (
<div <div
key={pc.id} key={pc.id}

View File

@@ -1,5 +1,5 @@
import { Database, Trash2 } from "lucide-react"; import { Database, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useOptimistic, useState } from "react";
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js"; import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
import * as bestiaryCache from "../adapters/bestiary-cache.js"; import * as bestiaryCache from "../adapters/bestiary-cache.js";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
@@ -10,6 +10,16 @@ interface SourceManagerProps {
export function SourceManager({ onCacheCleared }: SourceManagerProps) { export function SourceManager({ onCacheCleared }: SourceManagerProps) {
const [sources, setSources] = useState<CachedSourceInfo[]>([]); const [sources, setSources] = useState<CachedSourceInfo[]>([]);
const [optimisticSources, applyOptimistic] = useOptimistic(
sources,
(
state,
action: { type: "remove"; sourceCode: string } | { type: "clear" },
) =>
action.type === "clear"
? []
: state.filter((s) => s.sourceCode !== action.sourceCode),
);
const loadSources = useCallback(async () => { const loadSources = useCallback(async () => {
const cached = await bestiaryCache.getCachedSources(); const cached = await bestiaryCache.getCachedSources();
@@ -21,18 +31,20 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
}, [loadSources]); }, [loadSources]);
const handleClearSource = async (sourceCode: string) => { const handleClearSource = async (sourceCode: string) => {
applyOptimistic({ type: "remove", sourceCode });
await bestiaryCache.clearSource(sourceCode); await bestiaryCache.clearSource(sourceCode);
await loadSources(); await loadSources();
onCacheCleared(); onCacheCleared();
}; };
const handleClearAll = async () => { const handleClearAll = async () => {
applyOptimistic({ type: "clear" });
await bestiaryCache.clearAll(); await bestiaryCache.clearAll();
await loadSources(); await loadSources();
onCacheCleared(); onCacheCleared();
}; };
if (sources.length === 0) { if (optimisticSources.length === 0) {
return ( return (
<div className="flex flex-col items-center gap-2 py-8 text-center"> <div className="flex flex-col items-center gap-2 py-8 text-center">
<Database className="h-8 w-8 text-muted-foreground" /> <Database className="h-8 w-8 text-muted-foreground" />
@@ -57,7 +69,7 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
</Button> </Button>
</div> </div>
<ul className="flex flex-col gap-1"> <ul className="flex flex-col gap-1">
{sources.map((source) => ( {optimisticSources.map((source) => (
<li <li
key={source.sourceCode} key={source.sourceCode}
className="flex items-center justify-between rounded-md border border-border px-3 py-2" className="flex items-center justify-between rounded-md border border-border px-3 py-2"

View File

@@ -7,6 +7,7 @@ import type { BulkImportState } from "../hooks/use-bulk-import.js";
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js"; import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
import { BulkImportPrompt } from "./bulk-import-prompt.js"; import { BulkImportPrompt } from "./bulk-import-prompt.js";
import { SourceFetchPrompt } from "./source-fetch-prompt.js"; import { SourceFetchPrompt } from "./source-fetch-prompt.js";
import { SourceManager } from "./source-manager.js";
import { StatBlock } from "./stat-block.js"; import { StatBlock } from "./stat-block.js";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
@@ -21,8 +22,8 @@ interface StatBlockPanelProps {
) => Promise<void>; ) => Promise<void>;
refreshCache: () => Promise<void>; refreshCache: () => Promise<void>;
panelRole: "browse" | "pinned"; panelRole: "browse" | "pinned";
isFolded: boolean; isCollapsed: boolean;
onToggleFold: () => void; onToggleCollapse: () => void;
onPin: () => void; onPin: () => void;
onUnpin: () => void; onUnpin: () => void;
showPinButton: boolean; showPinButton: boolean;
@@ -32,6 +33,7 @@ interface StatBlockPanelProps {
bulkImportState?: BulkImportState; bulkImportState?: BulkImportState;
onStartBulkImport?: (baseUrl: string) => void; onStartBulkImport?: (baseUrl: string) => void;
onBulkImportDone?: () => void; onBulkImportDone?: () => void;
sourceManagerMode?: boolean;
} }
function extractSourceCode(cId: CreatureId): string { function extractSourceCode(cId: CreatureId): string {
@@ -40,23 +42,23 @@ function extractSourceCode(cId: CreatureId): string {
return cId.slice(0, colonIndex).toUpperCase(); return cId.slice(0, colonIndex).toUpperCase();
} }
function FoldedTab({ function CollapsedTab({
creatureName, creatureName,
side, side,
onToggleFold, onToggleCollapse,
}: { }: {
creatureName: string; creatureName: string;
side: "left" | "right"; side: "left" | "right";
onToggleFold: () => void; onToggleCollapse: () => void;
}) { }) {
return ( return (
<button <button
type="button" type="button"
onClick={onToggleFold} onClick={onToggleCollapse}
className={`flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral ${ 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" side === "right" ? "self-start" : "self-end"
}`} }`}
aria-label="Unfold stat block panel" aria-label="Expand stat block panel"
> >
<span className="writing-vertical-rl text-sm font-medium"> <span className="writing-vertical-rl text-sm font-medium">
{creatureName} {creatureName}
@@ -68,13 +70,13 @@ function FoldedTab({
function PanelHeader({ function PanelHeader({
panelRole, panelRole,
showPinButton, showPinButton,
onToggleFold, onToggleCollapse,
onPin, onPin,
onUnpin, onUnpin,
}: { }: {
panelRole: "browse" | "pinned"; panelRole: "browse" | "pinned";
showPinButton: boolean; showPinButton: boolean;
onToggleFold: () => void; onToggleCollapse: () => void;
onPin: () => void; onPin: () => void;
onUnpin: () => void; onUnpin: () => void;
}) { }) {
@@ -85,9 +87,9 @@ function PanelHeader({
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
onClick={onToggleFold} onClick={onToggleCollapse}
className="text-muted-foreground" className="text-muted-foreground"
aria-label="Fold stat block panel" aria-label="Collapse stat block panel"
> >
<PanelRightClose className="h-4 w-4" /> <PanelRightClose className="h-4 w-4" />
</Button> </Button>
@@ -122,48 +124,48 @@ function PanelHeader({
} }
function DesktopPanel({ function DesktopPanel({
isFolded, isCollapsed,
side, side,
creatureName, creatureName,
panelRole, panelRole,
showPinButton, showPinButton,
onToggleFold, onToggleCollapse,
onPin, onPin,
onUnpin, onUnpin,
children, children,
}: { }: {
isFolded: boolean; isCollapsed: boolean;
side: "left" | "right"; side: "left" | "right";
creatureName: string; creatureName: string;
panelRole: "browse" | "pinned"; panelRole: "browse" | "pinned";
showPinButton: boolean; showPinButton: boolean;
onToggleFold: () => void; onToggleCollapse: () => void;
onPin: () => void; onPin: () => void;
onUnpin: () => void; onUnpin: () => void;
children: ReactNode; children: ReactNode;
}) { }) {
const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l"; const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l";
const foldedTranslate = const collapsedTranslate =
side === "right" side === "right"
? "translate-x-[calc(100%-40px)]" ? "translate-x-[calc(100%-40px)]"
: "translate-x-[calc(-100%+40px)]"; : "translate-x-[calc(-100%+40px)]";
return ( return (
<div <div
className={`fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel ${sideClasses} ${isFolded ? foldedTranslate : "translate-x-0"}`} className={`fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel ${sideClasses} ${isCollapsed ? collapsedTranslate : "translate-x-0"}`}
> >
{isFolded ? ( {isCollapsed ? (
<FoldedTab <CollapsedTab
creatureName={creatureName} creatureName={creatureName}
side={side} side={side}
onToggleFold={onToggleFold} onToggleCollapse={onToggleCollapse}
/> />
) : ( ) : (
<> <>
<PanelHeader <PanelHeader
panelRole={panelRole} panelRole={panelRole}
showPinButton={showPinButton} showPinButton={showPinButton}
onToggleFold={onToggleFold} onToggleCollapse={onToggleCollapse}
onPin={onPin} onPin={onPin}
onUnpin={onUnpin} onUnpin={onUnpin}
/> />
@@ -204,7 +206,7 @@ function MobileDrawer({
size="icon-sm" size="icon-sm"
onClick={onDismiss} onClick={onDismiss}
className="text-muted-foreground" className="text-muted-foreground"
aria-label="Fold stat block panel" aria-label="Collapse stat block panel"
> >
<PanelRightClose className="h-4 w-4" /> <PanelRightClose className="h-4 w-4" />
</Button> </Button>
@@ -225,8 +227,8 @@ export function StatBlockPanel({
uploadAndCacheSource, uploadAndCacheSource,
refreshCache, refreshCache,
panelRole, panelRole,
isFolded, isCollapsed,
onToggleFold, onToggleCollapse,
onPin, onPin,
onUnpin, onUnpin,
showPinButton, showPinButton,
@@ -236,6 +238,7 @@ export function StatBlockPanel({
bulkImportState, bulkImportState,
onStartBulkImport, onStartBulkImport,
onBulkImportDone, onBulkImportDone,
sourceManagerMode,
}: StatBlockPanelProps) { }: StatBlockPanelProps) {
const [isDesktop, setIsDesktop] = useState( const [isDesktop, setIsDesktop] = useState(
() => window.matchMedia("(min-width: 1024px)").matches, () => window.matchMedia("(min-width: 1024px)").matches,
@@ -269,7 +272,7 @@ export function StatBlockPanel({
}); });
}, [creatureId, creature, isSourceCached]); }, [creatureId, creature, isSourceCached]);
if (!creatureId && !bulkImportMode) return null; if (!creatureId && !bulkImportMode && !sourceManagerMode) return null;
const sourceCode = creatureId ? extractSourceCode(creatureId) : ""; const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
@@ -279,6 +282,10 @@ export function StatBlockPanel({
}; };
const renderContent = () => { const renderContent = () => {
if (sourceManagerMode) {
return <SourceManager onCacheCleared={refreshCache} />;
}
if ( if (
bulkImportMode && bulkImportMode &&
bulkImportState && bulkImportState &&
@@ -324,17 +331,22 @@ export function StatBlockPanel({
}; };
const creatureName = const creatureName =
creature?.name ?? (bulkImportMode ? "Bulk Import" : "Creature"); creature?.name ??
(sourceManagerMode
? "Sources"
: bulkImportMode
? "Import All Sources"
: "Creature");
if (isDesktop) { if (isDesktop) {
return ( return (
<DesktopPanel <DesktopPanel
isFolded={isFolded} isCollapsed={isCollapsed}
side={side} side={side}
creatureName={creatureName} creatureName={creatureName}
panelRole={panelRole} panelRole={panelRole}
showPinButton={showPinButton} showPinButton={showPinButton}
onToggleFold={onToggleFold} onToggleCollapse={onToggleCollapse}
onPin={onPin} onPin={onPin}
onUnpin={onUnpin} onUnpin={onUnpin}
> >

View File

@@ -23,7 +23,7 @@ export function Toast({
}, [autoDismissMs, onDismiss]); }, [autoDismissMs, onDismiss]);
return createPortal( return createPortal(
<div className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2"> <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="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg">
<span className="text-sm text-foreground">{message}</span> <span className="text-sm text-foreground">{message}</span>
{progress !== undefined && ( {progress !== undefined && (

View File

@@ -3,7 +3,7 @@ import type {
Creature, Creature,
CreatureId, CreatureId,
} from "@initiative/domain"; } from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
normalizeBestiary, normalizeBestiary,
setSourceDisplayNames, setSourceDisplayNames,
@@ -33,8 +33,9 @@ interface BestiaryHook {
export function useBestiary(): BestiaryHook { export function useBestiary(): BestiaryHook {
const [isLoaded, setIsLoaded] = useState(false); const [isLoaded, setIsLoaded] = useState(false);
const creatureMapRef = useRef<Map<CreatureId, Creature>>(new Map()); const [creatureMap, setCreatureMap] = useState(
const [, setTick] = useState(0); () => new Map<CreatureId, Creature>(),
);
useEffect(() => { useEffect(() => {
const index = loadBestiaryIndex(); const index = loadBestiaryIndex();
@@ -44,8 +45,7 @@ export function useBestiary(): BestiaryHook {
} }
bestiaryCache.loadAllCachedCreatures().then((map) => { bestiaryCache.loadAllCachedCreatures().then((map) => {
creatureMapRef.current = map; setCreatureMap(map);
setTick((t) => t + 1);
}); });
}, []); }, []);
@@ -63,9 +63,12 @@ export function useBestiary(): BestiaryHook {
})); }));
}, []); }, []);
const getCreature = useCallback((id: CreatureId): Creature | undefined => { const getCreature = useCallback(
return creatureMapRef.current.get(id); (id: CreatureId): Creature | undefined => {
}, []); return creatureMap.get(id);
},
[creatureMap],
);
const isSourceCachedFn = useCallback( const isSourceCachedFn = useCallback(
(sourceCode: string): Promise<boolean> => { (sourceCode: string): Promise<boolean> => {
@@ -86,10 +89,13 @@ export function useBestiary(): BestiaryHook {
const creatures = normalizeBestiary(json); const creatures = normalizeBestiary(json);
const displayName = getSourceDisplayName(sourceCode); const displayName = getSourceDisplayName(sourceCode);
await bestiaryCache.cacheSource(sourceCode, displayName, creatures); await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
for (const c of creatures) { setCreatureMap((prev) => {
creatureMapRef.current.set(c.id, c); const next = new Map(prev);
} for (const c of creatures) {
setTick((t) => t + 1); next.set(c.id, c);
}
return next;
});
}, },
[], [],
); );
@@ -100,18 +106,20 @@ export function useBestiary(): BestiaryHook {
const creatures = normalizeBestiary(jsonData as any); const creatures = normalizeBestiary(jsonData as any);
const displayName = getSourceDisplayName(sourceCode); const displayName = getSourceDisplayName(sourceCode);
await bestiaryCache.cacheSource(sourceCode, displayName, creatures); await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
for (const c of creatures) { setCreatureMap((prev) => {
creatureMapRef.current.set(c.id, c); const next = new Map(prev);
} for (const c of creatures) {
setTick((t) => t + 1); next.set(c.id, c);
}
return next;
});
}, },
[], [],
); );
const refreshCache = useCallback(async (): Promise<void> => { const refreshCache = useCallback(async (): Promise<void> => {
const map = await bestiaryCache.loadAllCachedCreatures(); const map = await bestiaryCache.loadAllCachedCreatures();
creatureMapRef.current = map; setCreatureMap(map);
setTick((t) => t + 1);
}, []); }, []);
return { return {

View File

@@ -371,9 +371,20 @@ export function useEncounter() {
[makeStore, editCombatant], [makeStore, editCombatant],
); );
const isEmpty = encounter.combatants.length === 0;
const hasCreatureCombatants = encounter.combatants.some(
(c) => c.creatureId != null,
);
const canRollAllInitiative = encounter.combatants.some(
(c) => c.creatureId != null && c.initiative == null,
);
return { return {
encounter, encounter,
events, events,
isEmpty,
hasCreatureCombatants,
canRollAllInitiative,
advanceTurn, advanceTurn,
retreatTurn, retreatTurn,
addCombatant, addCombatant,

View File

@@ -26,8 +26,8 @@ interface EditFields {
readonly name?: string; readonly name?: string;
readonly ac?: number; readonly ac?: number;
readonly maxHp?: number; readonly maxHp?: number;
readonly color?: string; readonly color?: string | null;
readonly icon?: string; readonly icon?: string | null;
} }
export function usePlayerCharacters() { export function usePlayerCharacters() {
@@ -51,7 +51,13 @@ export function usePlayerCharacters() {
}, []); }, []);
const createCharacter = useCallback( const createCharacter = useCallback(
(name: string, ac: number, maxHp: number, color: string, icon: string) => { (
name: string,
ac: number,
maxHp: number,
color: string | undefined,
icon: string | undefined,
) => {
const id = generatePcId(); const id = generatePcId();
const result = createPlayerCharacterUseCase( const result = createPlayerCharacterUseCase(
makeStore(), makeStore(),

View File

@@ -0,0 +1,101 @@
import type { CreatureId } from "@initiative/domain";
import { useCallback, useEffect, useState } from "react";
type PanelView =
| { mode: "closed" }
| { mode: "creature"; creatureId: CreatureId }
| { mode: "bulk-import" }
| { mode: "source-manager" };
interface SidePanelState {
panelView: PanelView;
selectedCreatureId: CreatureId | null;
bulkImportMode: boolean;
sourceManagerMode: boolean;
isRightPanelCollapsed: boolean;
pinnedCreatureId: CreatureId | null;
isWideDesktop: boolean;
}
interface SidePanelActions {
showCreature: (creatureId: CreatureId) => void;
showBulkImport: () => void;
showSourceManager: () => void;
dismissPanel: () => void;
toggleCollapse: () => void;
togglePin: () => void;
unpin: () => void;
}
export function useSidePanelState(): SidePanelState & SidePanelActions {
const [panelView, setPanelView] = useState<PanelView>({ mode: "closed" });
const [isRightPanelCollapsed, setIsRightPanelCollapsed] = useState(false);
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
null,
);
const [isWideDesktop, setIsWideDesktop] = useState(
() => window.matchMedia("(min-width: 1280px)").matches,
);
useEffect(() => {
const mq = window.matchMedia("(min-width: 1280px)");
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const selectedCreatureId =
panelView.mode === "creature" ? panelView.creatureId : null;
const showCreature = useCallback((creatureId: CreatureId) => {
setPanelView({ mode: "creature", creatureId });
setIsRightPanelCollapsed(false);
}, []);
const showBulkImport = useCallback(() => {
setPanelView({ mode: "bulk-import" });
setIsRightPanelCollapsed(false);
}, []);
const showSourceManager = useCallback(() => {
setPanelView({ mode: "source-manager" });
setIsRightPanelCollapsed(false);
}, []);
const dismissPanel = useCallback(() => {
setPanelView({ mode: "closed" });
}, []);
const toggleCollapse = useCallback(() => {
setIsRightPanelCollapsed((f) => !f);
}, []);
const togglePin = useCallback(() => {
if (selectedCreatureId) {
setPinnedCreatureId((prev) =>
prev === selectedCreatureId ? null : selectedCreatureId,
);
}
}, [selectedCreatureId]);
const unpin = useCallback(() => {
setPinnedCreatureId(null);
}, []);
return {
panelView,
selectedCreatureId,
bulkImportMode: panelView.mode === "bulk-import",
sourceManagerMode: panelView.mode === "source-manager",
isRightPanelCollapsed,
pinnedCreatureId,
isWideDesktop,
showCreature,
showBulkImport,
showSourceManager,
dismissPanel,
toggleCollapse,
togglePin,
unpin,
};
}

View File

@@ -15,6 +15,13 @@ export function savePlayerCharacters(characters: PlayerCharacter[]): void {
} }
} }
function isValidOptionalMember(
value: unknown,
valid: ReadonlySet<string>,
): boolean {
return value === undefined || (typeof value === "string" && valid.has(value));
}
function rehydrateCharacter(raw: unknown): PlayerCharacter | null { function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) if (typeof raw !== "object" || raw === null || Array.isArray(raw))
return null; return null;
@@ -35,10 +42,8 @@ function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
entry.maxHp < 1 entry.maxHp < 1
) )
return null; return null;
if (typeof entry.color !== "string" || !VALID_PLAYER_COLORS.has(entry.color)) if (!isValidOptionalMember(entry.color, VALID_PLAYER_COLORS)) return null;
return null; if (!isValidOptionalMember(entry.icon, VALID_PLAYER_ICONS)) return null;
if (typeof entry.icon !== "string" || !VALID_PLAYER_ICONS.has(entry.icon))
return null;
return { return {
id: playerCharacterId(entry.id), id: playerCharacterId(entry.id),

View File

@@ -1,536 +0,0 @@
---
date: "2026-03-13T14:58:42.882813+00:00"
git_commit: 75778884bd1be7d135b2f5ea9b8a8e77a0149f7b
branch: main
topic: "Declutter Action Bars"
tags: [plan, turn-navigation, action-bar, overflow-menu, ux]
status: draft
---
# Declutter Action Bars — Implementation Plan
## Overview
Reorganize buttons across the top bar (TurnNavigation) and bottom bar (ActionBar) to reduce visual clutter and improve UX. Each bar gets a clear purpose: the top bar is for turn navigation + encounter lifecycle, the bottom bar is for adding combatants + setup actions.
## Current State Analysis
**Top bar** (`turn-navigation.tsx`) has 5 buttons + center info:
```
[ Prev ] | [ R1 Dwarf ] | [ D20 Library Trash ] [ Next ]
```
The D20 (roll all initiative) and Library (manage sources) buttons are unrelated to turn navigation — they're setup/utility actions that add noise.
**Bottom bar** (`action-bar.tsx`) has an input, Add button, and 3 icon buttons:
```
[ + Add combatants... ] [ Add ] [ Users Eye Import ]
```
The icon cluster (Users, Eye, Import) is cryptic — three ghost icon buttons with no labels, requiring hover to discover purpose. The Eye button opens a separate search dropdown for browsing stat blocks, which duplicates the existing search input.
### Key Discoveries:
- `rollAllInitiativeUseCase` (`packages/application/src/roll-all-initiative-use-case.ts`) applies to combatants with `creatureId` AND no `initiative` set — this defines the conditional visibility logic
- `Combatant.initiative` is `number | undefined` and `Combatant.creatureId` is `CreatureId | undefined` (`packages/domain/src/types.ts`)
- No existing dropdown/menu UI component — the overflow menu needs a new component
- Lucide provides `EllipsisVertical` for the kebab menu trigger
- The stat block viewer already has its own search input, results list, and keyboard navigation (`action-bar.tsx:65-236`) — in browse mode, we reuse the main input for this instead
## Desired End State
### UI Mockups
**Top bar (after):**
```
[ Prev ] [ R1 Dwarf ] [ Trash ] [ Next ]
```
4 elements. Clean, focused on turn flow + encounter lifecycle.
**Bottom bar — add mode (default):**
```
[ + Add combatants... 👁 ] [ Add ] [ D20? ] [ ⋮ ]
```
The Eye icon sits inside/beside the input as a toggle. D20 appears conditionally. Kebab menu holds infrequent actions.
**Bottom bar — browse mode (Eye toggled on):**
```
[ 🔍 Search stat blocks... 👁 ] [ ⋮ ]
```
The input switches purpose: placeholder changes, typing searches stat blocks instead of adding combatants. The Add button and D20 hide (irrelevant in browse mode). Eye icon stays as the toggle to switch back. Selecting a result opens the stat block panel and exits browse mode.
**Overflow menu (⋮ clicked):**
```
┌──────────────────────┐
│ 👥 Player Characters │
│ 📚 Manage Sources │
│ 📥 Bulk Import │
└──────────────────────┘
```
Labeled items with icons — discoverable without hover.
### Key Discoveries:
- `sourceManagerOpen` state lives in App.tsx:116 — the overflow menu's "Manage Sources" item needs the same toggle callback
- The stat block viewer state (viewerOpen, viewerQuery, viewerResults, viewerIndex) in action-bar.tsx:66-71 gets replaced by a `browseMode` boolean that repurposes the main input
- The viewer's separate input, dropdown, and keyboard handling (action-bar.tsx:188-248) can be removed — browse mode reuses the existing input and suggestion dropdown infrastructure
## What We're NOT Doing
- Changing domain logic or use cases
- Modifying ConfirmButton behavior
- Changing the stat block panel itself
- Altering animation logic (useActionBarAnimation)
- Modifying combatant row buttons
- Changing how SourceManager works (just moving where the trigger lives)
## Implementation Approach
Four phases, each independently testable. Phase 1 simplifies the top bar (pure removal). Phase 2 adds the overflow menu component. Phase 3 reworks the ActionBar (browse toggle + conditional D20 + overflow integration). Phase 4 wires everything together in App.tsx.
---
## Phase 1: Simplify TurnNavigation
### Overview
Strip TurnNavigation down to just turn controls + clear encounter. Remove Roll All Initiative and Manage Sources buttons and their associated props.
### Changes Required:
#### [x] 1. Update TurnNavigation component
**File**: `apps/web/src/components/turn-navigation.tsx`
**Changes**:
- Remove `onRollAllInitiative` and `onOpenSourceManager` from props interface
- Remove the D20 button (lines 53-62)
- Remove the Library button (lines 63-72)
- Remove the inner `gap-0` div wrapper (lines 52, 80) since only the ConfirmButton remains
- Remove unused imports: `Library` from lucide-react, `D20Icon`
- Adjust layout: ConfirmButton + Next button grouped with `gap-3`
Result:
```tsx
interface TurnNavigationProps {
encounter: Encounter;
onAdvanceTurn: () => void;
onRetreatTurn: () => void;
onClearEncounter: () => void;
}
// Layout becomes:
// [ Prev ] | [ R1 Name ] | [ Trash ] [ Next ]
```
#### [x] 2. Update TurnNavigation usage in App.tsx
**File**: `apps/web/src/App.tsx`
**Changes**:
- Remove `onRollAllInitiative` and `onOpenSourceManager` props from the `<TurnNavigation>` call (lines 256-257)
### Success Criteria:
#### Automated Verification:
- [x] `pnpm check` passes (typecheck catches removed props, lint catches unused imports)
#### Manual Verification:
- [ ] Top bar shows only: Prev, round badge + name, trash, Next
- [ ] Prev/Next/Clear buttons still work as before
- [ ] Top bar animation (slide in/out) unchanged
**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase.
---
## Phase 2: Create Overflow Menu Component
### Overview
Build a reusable overflow menu (kebab menu) component with click-outside and Escape handling, following the same patterns as ConfirmButton and the existing viewer dropdown.
### Changes Required:
#### [x] 1. Create OverflowMenu component
**File**: `apps/web/src/components/ui/overflow-menu.tsx` (new file)
**Changes**: Create a dropdown menu triggered by an EllipsisVertical icon button. Features:
- Toggle open/close on button click
- Close on click outside (document mousedown listener, same pattern as confirm-button.tsx:44-67)
- Close on Escape key
- Renders above the trigger (bottom-full positioning, same as action-bar suggestion dropdown)
- Each item: icon + label, full-width clickable row
- Clicking an item calls its action and closes the menu
```tsx
import { EllipsisVertical } from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { Button } from "./button";
export interface OverflowMenuItem {
readonly icon: ReactNode;
readonly label: string;
readonly onClick: () => void;
readonly disabled?: boolean;
}
interface OverflowMenuProps {
readonly items: readonly OverflowMenuItem[];
}
export function OverflowMenu({ items }: OverflowMenuProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleMouseDown(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
return (
<div ref={ref} className="relative">
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-hover-neutral"
onClick={() => setOpen((o) => !o)}
aria-label="More actions"
title="More actions"
>
<EllipsisVertical className="h-5 w-5" />
</Button>
{open && (
<div className="absolute bottom-full right-0 z-50 mb-1 min-w-48 rounded-md border border-border bg-card py-1 shadow-lg">
{items.map((item) => (
<button
key={item.label}
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-hover-neutral-bg disabled:pointer-events-none disabled:opacity-50"
disabled={item.disabled}
onClick={() => {
item.onClick();
setOpen(false);
}}
>
{item.icon}
{item.label}
</button>
))}
</div>
)}
</div>
);
}
```
### Success Criteria:
#### Automated Verification:
- [x] `pnpm check` passes (new file compiles, no unused exports yet — will be used in phase 3)
#### Manual Verification:
- [ ] N/A — component not yet wired into the UI
**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase.
---
## Phase 3: Rework ActionBar
### Overview
Replace the icon button cluster with: (1) an Eye toggle on the input that switches between add mode and browse mode, (2) a conditional Roll All Initiative button, and (3) the overflow menu for infrequent actions.
### Changes Required:
#### [x] 1. Update ActionBarProps
**File**: `apps/web/src/components/action-bar.tsx`
**Changes**: Add new props, keep existing ones needed for overflow menu items:
```tsx
interface ActionBarProps {
// ... existing props stay ...
onRollAllInitiative?: () => void; // new — moved from top bar
showRollAllInitiative?: boolean; // new — conditional visibility
onOpenSourceManager?: () => void; // new — moved from top bar
}
```
#### [x] 2. Add browse mode state
**File**: `apps/web/src/components/action-bar.tsx`
**Changes**: Replace the separate viewer state (viewerOpen, viewerQuery, viewerResults, viewerIndex, viewerRef, viewerInputRef — lines 66-71) with a single `browseMode` boolean:
```tsx
const [browseMode, setBrowseMode] = useState(false);
```
Remove all viewer-specific state variables and handlers:
- `viewerOpen`, `viewerQuery`, `viewerResults`, `viewerIndex` (lines 66-69)
- `viewerRef`, `viewerInputRef` (lines 70-71)
- `openViewer`, `closeViewer` (lines 189-202)
- `handleViewerQueryChange`, `handleViewerSelect`, `handleViewerKeyDown` (lines 204-236)
- The viewer click-outside effect (lines 239-248)
#### [x] 3. Rework the input area with Eye toggle
**File**: `apps/web/src/components/action-bar.tsx`
**Changes**: Add an Eye icon button inside the input wrapper that toggles browse mode. When browse mode is active:
- Placeholder changes to "Search stat blocks..."
- Typing calls `bestiarySearch` but selecting a result calls `onViewStatBlock` instead of queuing/adding
- The suggestion dropdown shows results but clicking opens stat block panel instead of adding
- Add button and custom fields (Init/AC/MaxHP) are hidden
- D20 button is hidden
When toggling browse mode off, clear the input and suggestions.
The Eye icon sits to the right of the input inside the `relative flex-1` wrapper:
```tsx
<div className="relative flex-1">
<Input
ref={inputRef}
type="text"
value={nameInput}
onChange={(e) => handleNameChange(e.target.value)}
onKeyDown={browseMode ? handleBrowseKeyDown : handleKeyDown}
placeholder={browseMode ? "Search stat blocks..." : "+ Add combatants"}
className="max-w-xs pr-8"
autoFocus={autoFocus}
/>
{bestiaryLoaded && onViewStatBlock && (
<button
type="button"
className={cn(
"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
browseMode && "text-accent",
)}
onClick={() => {
setBrowseMode((m) => !m);
setNameInput("");
setSuggestions([]);
setPcMatches([]);
setQueued(null);
setSuggestionIndex(-1);
}}
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
aria-label={browseMode ? "Switch to add mode" : "Browse stat blocks"}
>
<Eye className="h-4 w-4" />
</button>
)}
{/* suggestion dropdown — behavior changes based on browseMode */}
</div>
```
Import `cn` from `../../lib/utils` (already used by other components).
#### [x] 4. Update suggestion dropdown for browse mode
**File**: `apps/web/src/components/action-bar.tsx`
**Changes**: In browse mode, the suggestion dropdown behaves differently:
- No "Add as custom" row at the top
- No player character matches section
- No queuing (plus/minus/confirm) — clicking a result calls `onViewStatBlock` and exits browse mode
- Keyboard Enter on a highlighted result calls `onViewStatBlock` and exits browse mode
Add a `handleBrowseKeyDown` handler:
```tsx
const handleBrowseKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
setBrowseMode(false);
setNameInput("");
setSuggestions([]);
setSuggestionIndex(-1);
return;
}
if (suggestions.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
} else if (e.key === "Enter" && suggestionIndex >= 0) {
e.preventDefault();
onViewStatBlock?.(suggestions[suggestionIndex]);
setBrowseMode(false);
setNameInput("");
setSuggestions([]);
setSuggestionIndex(-1);
}
};
```
In the suggestion dropdown JSX, conditionally render based on `browseMode`:
- Browse mode: simple list of creature results, click → `onViewStatBlock` + exit browse mode
- Add mode: existing behavior (custom row, PC matches, queuing)
#### [x] 5. Replace icon button cluster with D20 + overflow menu
**File**: `apps/web/src/components/action-bar.tsx`
**Changes**: Replace the `div.flex.items-center.gap-0` block (lines 443-529) containing Users, Eye, and Import buttons with:
```tsx
{!browseMode && (
<>
<Button type="submit" size="sm">
Add
</Button>
{showRollAllInitiative && onRollAllInitiative && (
<Button
type="button"
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-hover-action"
onClick={onRollAllInitiative}
title="Roll all initiative"
aria-label="Roll all initiative"
>
<D20Icon className="h-6 w-6" />
</Button>
)}
</>
)}
<OverflowMenu items={overflowItems} />
```
Build the `overflowItems` array from props:
```tsx
const overflowItems: OverflowMenuItem[] = [];
if (onManagePlayers) {
overflowItems.push({
icon: <Users className="h-4 w-4" />,
label: "Player Characters",
onClick: onManagePlayers,
});
}
if (onOpenSourceManager) {
overflowItems.push({
icon: <Library className="h-4 w-4" />,
label: "Manage Sources",
onClick: onOpenSourceManager,
});
}
if (bestiaryLoaded && onBulkImport) {
overflowItems.push({
icon: <Import className="h-4 w-4" />,
label: "Bulk Import",
onClick: onBulkImport,
disabled: bulkImportDisabled,
});
}
```
#### [x] 6. Clean up imports
**File**: `apps/web/src/components/action-bar.tsx`
**Changes**:
- Add imports: `D20Icon`, `OverflowMenu` + `OverflowMenuItem`, `Library` from lucide-react, `cn` from utils
- Remove imports that are no longer needed after removing the standalone viewer: check which of `Eye`, `Import`, `Users` are still used (Eye stays for the toggle, Users and Import stay for overflow item icons, Library is new)
- The `Check`, `Minus`, `Plus` imports stay (used in queuing UI)
### Success Criteria:
#### Automated Verification:
- [x] `pnpm check` passes
#### Manual Verification:
- [ ] Bottom bar shows: input with Eye toggle, Add button, (conditional D20), kebab menu
- [ ] Eye toggle switches input between "add" and "browse" modes
- [ ] In browse mode: typing shows bestiary results, clicking one opens stat block panel, exits browse mode
- [ ] In browse mode: Add button and D20 are hidden, overflow menu stays visible
- [ ] In add mode: existing behavior works (search, queue, custom fields, PC matches)
- [ ] Overflow menu opens/closes on click, closes on Escape and click-outside
- [ ] Overflow menu items (Player Characters, Manage Sources, Bulk Import) trigger correct actions
- [ ] D20 button appears only when bestiary combatants lack initiative, disappears when all have values
**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase.
---
## Phase 4: Wire Up App.tsx
### Overview
Pass the new props to ActionBar — roll all initiative handler, conditional visibility flag, and source manager toggle. Remove the now-unused `onOpenSourceManager` callback from the TurnNavigation call (already removed in Phase 1) and ensure sourceManagerOpen toggle is routed through the overflow menu.
### Changes Required:
#### [x] 1. Compute showRollAllInitiative flag
**File**: `apps/web/src/App.tsx`
**Changes**: Add a derived boolean that checks if any combatant with a `creatureId` lacks an `initiative` value:
```tsx
const showRollAllInitiative = encounter.combatants.some(
(c) => c.creatureId != null && c.initiative == null,
);
```
Place this near `const isEmpty = ...` (line 241).
#### [x] 2. Pass new props to both ActionBar instances
**File**: `apps/web/src/App.tsx`
**Changes**: Add to both `<ActionBar>` calls (empty state at ~line 269 and populated state at ~line 328):
```tsx
<ActionBar
// ... existing props ...
onRollAllInitiative={handleRollAllInitiative}
showRollAllInitiative={showRollAllInitiative}
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
/>
```
#### [x] 3. Remove stale code
**File**: `apps/web/src/App.tsx`
**Changes**:
- The `onRollAllInitiative` and `onOpenSourceManager` props were already removed from `<TurnNavigation>` in Phase 1 — verify no references remain
- Verify `sourceManagerOpen` state and the `<SourceManager>` rendering block (lines 287-291) still work correctly — the SourceManager inline panel is still toggled by the same state, just from a different trigger location
### Success Criteria:
#### Automated Verification:
- [x] `pnpm check` passes
#### Manual Verification:
- [ ] Top bar: only Prev, round badge + name, trash, Next — no D20 or Library buttons
- [ ] Bottom bar: input with Eye toggle, Add, conditional D20, overflow menu
- [ ] Roll All Initiative (D20 in bottom bar): visible when bestiary creatures lack initiative, hidden after rolling
- [ ] Overflow → Player Characters: opens player management modal
- [ ] Overflow → Manage Sources: toggles source manager panel (same as before, just different trigger)
- [ ] Overflow → Bulk Import: opens bulk import mode
- [ ] Browse mode (Eye toggle): search stat blocks without adding, selecting opens panel
- [ ] Clear encounter (top bar trash): still works with two-click confirmation
- [ ] All animations (bar transitions) unchanged
- [ ] Empty state: ActionBar centered with all functionality accessible
**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase.
---
## Testing Strategy
### Unit Tests:
- No domain/application changes — existing tests should pass unchanged
- `pnpm check` covers typecheck + lint + existing test suite
### Manual Testing Steps:
1. Start with empty encounter — verify ActionBar is centered with Eye toggle and overflow menu
2. Add a bestiary creature — verify D20 appears in bottom bar, top bar slides in with just 4 elements
3. Click D20 → initiative rolls → D20 disappears from bottom bar
4. Toggle Eye → input switches to browse mode → search and select → stat block opens → exits browse mode
5. Open overflow menu → click each item → verify correct modal/panel opens
6. Click trash in top bar → confirm → encounter clears, back to empty state
7. Add custom creature (no creatureId) → D20 should not appear (no bestiary creatures)
8. Add mix of custom + bestiary creatures → D20 visible → roll all → D20 hidden
## Performance Considerations
None — this is a pure UI reorganization with no new data fetching, state management changes, or rendering overhead. The `showRollAllInitiative` computation is a simple `.some()` over the combatant array, which is negligible.
## References
- Research: `docs/agents/research/2026-03-13-action-bars-and-buttons.md`
- Top bar: `apps/web/src/components/turn-navigation.tsx`
- Bottom bar: `apps/web/src/components/action-bar.tsx`
- App layout: `apps/web/src/App.tsx`
- Button: `apps/web/src/components/ui/button.tsx`
- ConfirmButton: `apps/web/src/components/ui/confirm-button.tsx`
- Roll all use case: `packages/application/src/roll-all-initiative-use-case.ts`
- Combatant type: `packages/domain/src/types.ts`

View File

@@ -1,6 +1,11 @@
{ {
"private": true, "private": true,
"packageManager": "pnpm@10.6.0", "packageManager": "pnpm@10.6.0",
"pnpm": {
"overrides": {
"undici": ">=7.24.0"
}
},
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.0.0", "@biomejs/biome": "2.0.0",
"@vitest/coverage-v8": "^3.2.4", "@vitest/coverage-v8": "^3.2.4",

View File

@@ -13,8 +13,8 @@ export function createPlayerCharacterUseCase(
name: string, name: string,
ac: number, ac: number,
maxHp: number, maxHp: number,
color: string, color: string | undefined,
icon: string, icon: string | undefined,
): DomainEvent[] | DomainError { ): DomainEvent[] | DomainError {
const characters = store.getAll(); const characters = store.getAll();
const result = createPlayerCharacter( const result = createPlayerCharacter(

View File

@@ -11,8 +11,8 @@ interface EditFields {
readonly name?: string; readonly name?: string;
readonly ac?: number; readonly ac?: number;
readonly maxHp?: number; readonly maxHp?: number;
readonly color?: string; readonly color?: string | null;
readonly icon?: string; readonly icon?: string | null;
} }
export function editPlayerCharacterUseCase( export function editPlayerCharacterUseCase(

View File

@@ -13,7 +13,10 @@ export type {
} from "./ports.js"; } from "./ports.js";
export { removeCombatantUseCase } from "./remove-combatant-use-case.js"; export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
export { retreatTurnUseCase } from "./retreat-turn-use-case.js"; export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
export { rollAllInitiativeUseCase } from "./roll-all-initiative-use-case.js"; export {
type RollAllResult,
rollAllInitiativeUseCase,
} from "./roll-all-initiative-use-case.js";
export { rollInitiativeUseCase } from "./roll-initiative-use-case.js"; export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
export { setAcUseCase } from "./set-ac-use-case.js"; export { setAcUseCase } from "./set-ac-use-case.js";
export { setHpUseCase } from "./set-hp-use-case.js"; export { setHpUseCase } from "./set-hp-use-case.js";

View File

@@ -10,20 +10,29 @@ import {
} from "@initiative/domain"; } from "@initiative/domain";
import type { EncounterStore } from "./ports.js"; import type { EncounterStore } from "./ports.js";
export interface RollAllResult {
events: DomainEvent[];
skippedNoSource: number;
}
export function rollAllInitiativeUseCase( export function rollAllInitiativeUseCase(
store: EncounterStore, store: EncounterStore,
rollDice: () => number, rollDice: () => number,
getCreature: (id: CreatureId) => Creature | undefined, getCreature: (id: CreatureId) => Creature | undefined,
): DomainEvent[] | DomainError { ): RollAllResult | DomainError {
let encounter = store.get(); let encounter = store.get();
const allEvents: DomainEvent[] = []; const allEvents: DomainEvent[] = [];
let skippedNoSource = 0;
for (const combatant of encounter.combatants) { for (const combatant of encounter.combatants) {
if (!combatant.creatureId) continue; if (!combatant.creatureId) continue;
if (combatant.initiative !== undefined) continue; if (combatant.initiative !== undefined) continue;
const creature = getCreature(combatant.creatureId); const creature = getCreature(combatant.creatureId);
if (!creature) continue; if (!creature) {
skippedNoSource++;
continue;
}
const { modifier } = calculateInitiative({ const { modifier } = calculateInitiative({
dexScore: creature.abilities.dex, dexScore: creature.abilities.dex,
@@ -47,5 +56,5 @@ export function rollAllInitiativeUseCase(
} }
store.save(encounter); store.save(encounter);
return allEvents; return { events: allEvents, skippedNoSource };
} }

View File

@@ -219,6 +219,49 @@ describe("createPlayerCharacter", () => {
} }
}); });
it("allows undefined color", () => {
const result = createPlayerCharacter(
[],
id,
"Test",
10,
50,
undefined,
"sword",
);
if (isDomainError(result)) throw new Error(result.message);
expect(result.characters[0].color).toBeUndefined();
});
it("allows undefined icon", () => {
const result = createPlayerCharacter(
[],
id,
"Test",
10,
50,
"blue",
undefined,
);
if (isDomainError(result)) throw new Error(result.message);
expect(result.characters[0].icon).toBeUndefined();
});
it("allows both color and icon undefined", () => {
const result = createPlayerCharacter(
[],
id,
"Test",
10,
50,
undefined,
undefined,
);
if (isDomainError(result)) throw new Error(result.message);
expect(result.characters[0].color).toBeUndefined();
expect(result.characters[0].icon).toBeUndefined();
});
it("emits exactly one event on success", () => { it("emits exactly one event on success", () => {
const { events } = success([], "Test", 10, 50); const { events } = success([], "Test", 10, 50);
expect(events).toHaveLength(1); expect(events).toHaveLength(1);

View File

@@ -106,6 +106,22 @@ describe("editPlayerCharacter", () => {
expect(result.events).toHaveLength(1); expect(result.events).toHaveLength(1);
}); });
it("clears color when set to null", () => {
const result = editPlayerCharacter([makePC({ color: "green" })], id, {
color: null,
});
if (isDomainError(result)) throw new Error(result.message);
expect(result.characters[0].color).toBeUndefined();
});
it("clears icon when set to null", () => {
const result = editPlayerCharacter([makePC({ icon: "sword" })], id, {
icon: null,
});
if (isDomainError(result)) throw new Error(result.message);
expect(result.characters[0].icon).toBeUndefined();
});
it("event includes old and new name", () => { it("event includes old and new name", () => {
const result = editPlayerCharacter([makePC()], id, { name: "Strider" }); const result = editPlayerCharacter([makePC()], id, { name: "Strider" });
if (isDomainError(result)) throw new Error(result.message); if (isDomainError(result)) throw new Error(result.message);

View File

@@ -20,8 +20,8 @@ export function createPlayerCharacter(
name: string, name: string,
ac: number, ac: number,
maxHp: number, maxHp: number,
color: string, color: string | undefined,
icon: string, icon: string | undefined,
): CreatePlayerCharacterSuccess | DomainError { ): CreatePlayerCharacterSuccess | DomainError {
const trimmed = name.trim(); const trimmed = name.trim();
@@ -49,7 +49,7 @@ export function createPlayerCharacter(
}; };
} }
if (!VALID_PLAYER_COLORS.has(color)) { if (color !== undefined && !VALID_PLAYER_COLORS.has(color)) {
return { return {
kind: "domain-error", kind: "domain-error",
code: "invalid-color", code: "invalid-color",
@@ -57,7 +57,7 @@ export function createPlayerCharacter(
}; };
} }
if (!VALID_PLAYER_ICONS.has(icon)) { if (icon !== undefined && !VALID_PLAYER_ICONS.has(icon)) {
return { return {
kind: "domain-error", kind: "domain-error",
code: "invalid-icon", code: "invalid-icon",

View File

@@ -18,8 +18,8 @@ interface EditFields {
readonly name?: string; readonly name?: string;
readonly ac?: number; readonly ac?: number;
readonly maxHp?: number; readonly maxHp?: number;
readonly color?: string; readonly color?: string | null;
readonly icon?: string; readonly icon?: string | null;
} }
function validateFields(fields: EditFields): DomainError | null { function validateFields(fields: EditFields): DomainError | null {
@@ -50,14 +50,22 @@ function validateFields(fields: EditFields): DomainError | null {
message: "Max HP must be a positive integer", message: "Max HP must be a positive integer",
}; };
} }
if (fields.color !== undefined && !VALID_PLAYER_COLORS.has(fields.color)) { if (
fields.color !== undefined &&
fields.color !== null &&
!VALID_PLAYER_COLORS.has(fields.color)
) {
return { return {
kind: "domain-error", kind: "domain-error",
code: "invalid-color", code: "invalid-color",
message: `Invalid color: ${fields.color}`, message: `Invalid color: ${fields.color}`,
}; };
} }
if (fields.icon !== undefined && !VALID_PLAYER_ICONS.has(fields.icon)) { if (
fields.icon !== undefined &&
fields.icon !== null &&
!VALID_PLAYER_ICONS.has(fields.icon)
) {
return { return {
kind: "domain-error", kind: "domain-error",
code: "invalid-icon", code: "invalid-icon",
@@ -78,11 +86,11 @@ function applyFields(
maxHp: fields.maxHp !== undefined ? fields.maxHp : existing.maxHp, maxHp: fields.maxHp !== undefined ? fields.maxHp : existing.maxHp,
color: color:
fields.color !== undefined fields.color !== undefined
? (fields.color as PlayerCharacter["color"]) ? ((fields.color as PlayerCharacter["color"]) ?? undefined)
: existing.color, : existing.color,
icon: icon:
fields.icon !== undefined fields.icon !== undefined
? (fields.icon as PlayerCharacter["icon"]) ? ((fields.icon as PlayerCharacter["icon"]) ?? undefined)
: existing.icon, : existing.icon,
}; };
} }

View File

@@ -72,8 +72,8 @@ export interface PlayerCharacter {
readonly name: string; readonly name: string;
readonly ac: number; readonly ac: number;
readonly maxHp: number; readonly maxHp: number;
readonly color: PlayerColor; readonly color?: PlayerColor;
readonly icon: PlayerIcon; readonly icon?: PlayerIcon;
} }
export interface PlayerCharacterList { export interface PlayerCharacterList {

11
pnpm-lock.yaml generated
View File

@@ -4,6 +4,9 @@ settings:
autoInstallPeers: true autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
overrides:
undici: '>=7.24.0'
importers: importers:
.: .:
@@ -2011,8 +2014,8 @@ packages:
undici-types@7.18.2: undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
undici@7.22.0: undici@7.24.2:
resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} resolution: {integrity: sha512-P9J1HWYV/ajFr8uCqk5QixwiRKmB1wOamgS0e+o2Z4A44Ej2+thFVRLG/eA7qprx88XXhnV5Bl8LHXTURpzB3Q==}
engines: {node: '>=20.18.1'} engines: {node: '>=20.18.1'}
universalify@2.0.1: universalify@2.0.1:
@@ -3420,7 +3423,7 @@ snapshots:
saxes: 6.0.0 saxes: 6.0.0
symbol-tree: 3.2.4 symbol-tree: 3.2.4
tough-cookie: 6.0.0 tough-cookie: 6.0.0
undici: 7.22.0 undici: 7.24.2
w3c-xmlserializer: 5.0.0 w3c-xmlserializer: 5.0.0
webidl-conversions: 8.0.1 webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0 whatwg-mimetype: 5.0.0
@@ -3973,7 +3976,7 @@ snapshots:
undici-types@7.18.2: {} undici-types@7.18.2: {}
undici@7.22.0: {} undici@7.24.2: {}
universalify@2.0.1: {} universalify@2.0.1: {}