24 Commits
0.5.2 ... 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
Lukas
07cdd4867a Fix custom combatant form disappearing when dismissing suggestions
All checks were successful
CI / check (push) Successful in 46s
CI / build-image (push) Successful in 18s
The "Add as custom" button and Escape key were clearing the name input
along with the suggestions, preventing the custom fields (Init, AC,
MaxHP) from ever appearing. Now only the suggestions are dismissed,
keeping the typed name intact so the custom combatant form renders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:30:17 +01:00
Lukas
85acb5c185 Migrate icon buttons to Button component and simplify size variants
All checks were successful
CI / check (push) Successful in 48s
CI / build-image (push) Successful in 18s
Replace raw <button> elements with Button variant="ghost" in stat-block
panel, toast, player modals. Add icon-sm size variant (h-6 w-6) for
compact contexts. Consolidate text button sizes into a single default
(h-8 px-3), removing the redundant sm variant. Add size prop to
ConfirmButton for consistent sizing.

Button now has three sizes: default (text), icon, icon-sm.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:22:00 +01:00
Lukas
f9ef64bb00 Unify hover effects via semantic theme tokens
Replace one-off hover colors with hover-neutral/hover-destructive tokens
so all interactive elements respond consistently to theme changes. Fix
hover-neutral-bg token value (was identical to card surface, making hover
invisible on card backgrounds) to a semi-transparent primary tint. Switch
turn nav buttons to outline variant for visible hover feedback. Convert HP
popover damage/heal to plain buttons to avoid ghost variant hover conflict
with tailwind-merge.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:58:01 +01:00
Lukas
bd39808000 Declutter action bars: overflow menu, browse toggle, conditional D20
All checks were successful
CI / check (push) Successful in 48s
CI / build-image (push) Successful in 18s
Top bar stripped to turn navigation only (Prev, round badge, Clear, Next).
Roll All Initiative, Manage Sources, and Bulk Import moved to a new
overflow menu in the bottom bar. Player Characters also moved there.

Browse stat blocks is now an Eye/EyeOff toggle inside the search input
that switches between add mode and browse mode. Add button only appears
when entering a custom creature name. Roll All Initiative button shows
conditionally — only when bestiary creatures lack initiative values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:31:25 +01:00
41 changed files with 1507 additions and 736 deletions

1
.gitignore vendored
View File

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

View File

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

View File

@@ -26,8 +26,6 @@ function renderNav(overrides: Partial<Encounter> = {}) {
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>,
);
}
@@ -72,8 +70,6 @@ describe("TurnNavigation", () => {
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>,
);
expect(screen.getByText("R2")).toBeInTheDocument();
@@ -88,8 +84,6 @@ describe("TurnNavigation", () => {
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>,
);
expect(screen.getByText("R3")).toBeInTheDocument();
@@ -110,8 +104,6 @@ describe("TurnNavigation", () => {
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>,
);
expect(screen.getByText("Goblin")).toBeInTheDocument();
@@ -129,8 +121,6 @@ describe("TurnNavigation", () => {
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>,
);
expect(screen.getByText("Conjurer")).toBeInTheDocument();
@@ -173,16 +163,6 @@ describe("TurnNavigation", () => {
expect(
screen.getByRole("button", { name: "Next turn" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", {
name: "Roll all initiative",
}),
).toBeInTheDocument();
expect(
screen.getByRole("button", {
name: "Manage cached sources",
}),
).toBeInTheDocument();
});
it("renders a 40-character name without truncation class issues", () => {

View File

@@ -1,16 +1,27 @@
import type { PlayerCharacter, PlayerIcon } from "@initiative/domain";
import { Check, Eye, Import, Minus, Plus, Users } from "lucide-react";
import {
Check,
Eye,
EyeOff,
Import,
Library,
Minus,
Plus,
Users,
} from "lucide-react";
import {
type FormEvent,
type RefObject,
useEffect,
useRef,
useDeferredValue,
useState,
} from "react";
import type { SearchResult } from "../hooks/use-bestiary.js";
import { cn } from "../lib/utils.js";
import { D20Icon } from "./d20-icon.js";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
interface QueuedCreature {
result: SearchResult;
@@ -32,6 +43,10 @@ interface ActionBarProps {
playerCharacters?: readonly PlayerCharacter[];
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
onManagePlayers?: () => void;
onRollAllInitiative?: () => void;
showRollAllInitiative?: boolean;
rollAllInitiativeDisabled?: boolean;
onOpenSourceManager?: () => void;
autoFocus?: boolean;
}
@@ -39,243 +54,40 @@ function creatureKey(r: SearchResult): string {
return `${r.source}:${r.name}`;
}
export function ActionBar({
onAddCombatant,
onAddFromBestiary,
bestiarySearch,
bestiaryLoaded,
onViewStatBlock,
onBulkImport,
bulkImportDisabled,
inputRef,
playerCharacters,
function AddModeSuggestions({
nameInput,
suggestions,
pcMatches,
suggestionIndex,
queued,
onDismiss,
onClickSuggestion,
onSetSuggestionIndex,
onSetQueued,
onConfirmQueued,
onAddFromPlayerCharacter,
onManagePlayers,
autoFocus,
}: ActionBarProps) {
const [nameInput, setNameInput] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
const [suggestionIndex, setSuggestionIndex] = useState(-1);
const [queued, setQueued] = useState<QueuedCreature | null>(null);
const [customInit, setCustomInit] = useState("");
const [customAc, setCustomAc] = useState("");
const [customMaxHp, setCustomMaxHp] = useState("");
// Stat block viewer: separate dropdown
const [viewerOpen, setViewerOpen] = useState(false);
const [viewerQuery, setViewerQuery] = useState("");
const [viewerResults, setViewerResults] = useState<SearchResult[]>([]);
const [viewerIndex, setViewerIndex] = useState(-1);
const viewerRef = useRef<HTMLDivElement>(null);
const viewerInputRef = useRef<HTMLInputElement>(null);
const clearCustomFields = () => {
setCustomInit("");
setCustomAc("");
setCustomMaxHp("");
};
const confirmQueued = () => {
if (!queued) return;
for (let i = 0; i < queued.count; i++) {
onAddFromBestiary(queued.result);
}
setQueued(null);
setNameInput("");
setSuggestions([]);
setPcMatches([]);
setSuggestionIndex(-1);
};
const parseNum = (v: string): number | undefined => {
if (v.trim() === "") return undefined;
const n = Number(v);
return Number.isNaN(n) ? undefined : n;
};
const handleAdd = (e: FormEvent) => {
e.preventDefault();
if (queued) {
confirmQueued();
return;
}
if (nameInput.trim() === "") return;
const opts: { initiative?: number; ac?: number; maxHp?: number } = {};
const init = parseNum(customInit);
const ac = parseNum(customAc);
const maxHp = parseNum(customMaxHp);
if (init !== undefined) opts.initiative = init;
if (ac !== undefined) opts.ac = ac;
if (maxHp !== undefined) opts.maxHp = maxHp;
onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
setNameInput("");
setSuggestions([]);
setPcMatches([]);
clearCustomFields();
};
const handleNameChange = (value: string) => {
setNameInput(value);
setSuggestionIndex(-1);
let newSuggestions: SearchResult[] = [];
let newPcMatches: PlayerCharacter[] = [];
if (value.length >= 2) {
newSuggestions = bestiarySearch(value);
setSuggestions(newSuggestions);
if (playerCharacters && playerCharacters.length > 0) {
const lower = value.toLowerCase();
newPcMatches = playerCharacters.filter((pc) =>
pc.name.toLowerCase().includes(lower),
);
}
setPcMatches(newPcMatches);
} else {
setSuggestions([]);
setPcMatches([]);
}
if (newSuggestions.length > 0 || newPcMatches.length > 0) {
clearCustomFields();
}
if (queued) {
const qKey = creatureKey(queued.result);
const stillVisible = newSuggestions.some((s) => creatureKey(s) === qKey);
if (!stillVisible) {
setQueued(null);
}
}
};
const handleClickSuggestion = (result: SearchResult) => {
const key = creatureKey(result);
if (queued && creatureKey(queued.result) === key) {
setQueued({ ...queued, count: queued.count + 1 });
} else {
setQueued({ result, count: 1 });
}
};
const handleEnter = () => {
if (queued) {
confirmQueued();
} else if (suggestionIndex >= 0) {
handleClickSuggestion(suggestions[suggestionIndex]);
}
};
const hasSuggestions = suggestions.length > 0 || pcMatches.length > 0;
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!hasSuggestions) 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") {
e.preventDefault();
handleEnter();
} else if (e.key === "Escape") {
setQueued(null);
setSuggestionIndex(-1);
setSuggestions([]);
setPcMatches([]);
}
};
// Stat block viewer dropdown handlers
const openViewer = () => {
setViewerOpen(true);
setViewerQuery("");
setViewerResults([]);
setViewerIndex(-1);
requestAnimationFrame(() => viewerInputRef.current?.focus());
};
const closeViewer = () => {
setViewerOpen(false);
setViewerQuery("");
setViewerResults([]);
setViewerIndex(-1);
};
const handleViewerQueryChange = (value: string) => {
setViewerQuery(value);
setViewerIndex(-1);
if (value.length >= 2) {
setViewerResults(bestiarySearch(value));
} else {
setViewerResults([]);
}
};
const handleViewerSelect = (result: SearchResult) => {
onViewStatBlock?.(result);
closeViewer();
};
const handleViewerKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
closeViewer();
return;
}
if (viewerResults.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setViewerIndex((i) => (i < viewerResults.length - 1 ? i + 1 : 0));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setViewerIndex((i) => (i > 0 ? i - 1 : viewerResults.length - 1));
} else if (e.key === "Enter" && viewerIndex >= 0) {
e.preventDefault();
handleViewerSelect(viewerResults[viewerIndex]);
}
};
// Close viewer on outside click
useEffect(() => {
if (!viewerOpen) return;
function handleClickOutside(e: MouseEvent) {
if (viewerRef.current && !viewerRef.current.contains(e.target as Node)) {
closeViewer();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [viewerOpen]);
onClear,
}: {
nameInput: string;
suggestions: SearchResult[];
pcMatches: PlayerCharacter[];
suggestionIndex: number;
queued: QueuedCreature | null;
onDismiss: () => void;
onClear: () => void;
onClickSuggestion: (result: SearchResult) => void;
onSetSuggestionIndex: (i: number) => void;
onSetQueued: (q: QueuedCreature | null) => void;
onConfirmQueued: () => void;
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
}) {
return (
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
<form
onSubmit={handleAdd}
className="relative flex flex-1 items-center gap-2"
>
<div className="relative flex-1">
<Input
ref={inputRef}
type="text"
value={nameInput}
onChange={(e) => handleNameChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="+ Add combatants"
className="max-w-xs"
autoFocus={autoFocus}
/>
{hasSuggestions && (
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
<button
type="button"
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-2 text-left text-sm text-accent hover:bg-accent/20"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setSuggestions([]);
setPcMatches([]);
setQueued(null);
setSuggestionIndex(-1);
}}
onClick={onDismiss}
>
<Plus className="h-3.5 w-3.5" />
<span className="flex-1">Add "{nameInput}" as custom</span>
@@ -291,11 +103,12 @@ export function ActionBar({
</div>
<ul>
{pcMatches.map((pc) => {
const PcIcon = PLAYER_ICON_MAP[pc.icon as PlayerIcon];
const pcColor =
PLAYER_COLOR_HEX[
pc.color as keyof typeof PLAYER_COLOR_HEX
];
const PcIcon = pc.icon
? PLAYER_ICON_MAP[pc.icon as PlayerIcon]
: undefined;
const pcColor = pc.color
? PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX]
: undefined;
return (
<li key={pc.id}>
<button
@@ -304,9 +117,7 @@ export function ActionBar({
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onAddFromPlayerCharacter?.(pc);
setNameInput("");
setSuggestions([]);
setPcMatches([]);
onClear();
}}
>
{PcIcon && (
@@ -341,8 +152,8 @@ export function ActionBar({
: "text-foreground hover:bg-hover-neutral-bg"
}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleClickSuggestion(result)}
onMouseEnter={() => setSuggestionIndex(i)}
onClick={() => onClickSuggestion(result)}
onMouseEnter={() => onSetSuggestionIndex(i)}
>
<span>{result.name}</span>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
@@ -355,9 +166,9 @@ export function ActionBar({
onClick={(e) => {
e.stopPropagation();
if (queued.count <= 1) {
setQueued(null);
onSetQueued(null);
} else {
setQueued({
onSetQueued({
...queued,
count: queued.count - 1,
});
@@ -375,7 +186,7 @@ export function ActionBar({
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
setQueued({
onSetQueued({
...queued,
count: queued.count + 1,
});
@@ -389,7 +200,7 @@ export function ActionBar({
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
confirmQueued();
onConfirmQueued();
}}
>
<Check className="h-3.5 w-3.5" />
@@ -407,9 +218,336 @@ export function ActionBar({
)}
</div>
</div>
);
}
function buildOverflowItems(opts: {
onManagePlayers?: () => void;
onOpenSourceManager?: () => void;
bestiaryLoaded: boolean;
onBulkImport?: () => void;
bulkImportDisabled?: boolean;
}): OverflowMenuItem[] {
const items: OverflowMenuItem[] = [];
if (opts.onManagePlayers) {
items.push({
icon: <Users className="h-4 w-4" />,
label: "Player Characters",
onClick: opts.onManagePlayers,
});
}
if (opts.onOpenSourceManager) {
items.push({
icon: <Library className="h-4 w-4" />,
label: "Manage Sources",
onClick: opts.onOpenSourceManager,
});
}
if (opts.bestiaryLoaded && opts.onBulkImport) {
items.push({
icon: <Import className="h-4 w-4" />,
label: "Import All Sources",
onClick: opts.onBulkImport,
disabled: opts.bulkImportDisabled,
});
}
return items;
}
export function ActionBar({
onAddCombatant,
onAddFromBestiary,
bestiarySearch,
bestiaryLoaded,
onViewStatBlock,
onBulkImport,
bulkImportDisabled,
inputRef,
playerCharacters,
onAddFromPlayerCharacter,
onManagePlayers,
onRollAllInitiative,
showRollAllInitiative,
rollAllInitiativeDisabled,
onOpenSourceManager,
autoFocus,
}: ActionBarProps) {
const [nameInput, setNameInput] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
const deferredSuggestions = useDeferredValue(suggestions);
const deferredPcMatches = useDeferredValue(pcMatches);
const [suggestionIndex, setSuggestionIndex] = useState(-1);
const [queued, setQueued] = useState<QueuedCreature | null>(null);
const [customInit, setCustomInit] = useState("");
const [customAc, setCustomAc] = useState("");
const [customMaxHp, setCustomMaxHp] = useState("");
const [browseMode, setBrowseMode] = useState(false);
const clearCustomFields = () => {
setCustomInit("");
setCustomAc("");
setCustomMaxHp("");
};
const clearInput = () => {
setNameInput("");
setSuggestions([]);
setPcMatches([]);
setQueued(null);
setSuggestionIndex(-1);
};
const dismissSuggestions = () => {
setSuggestions([]);
setPcMatches([]);
setQueued(null);
setSuggestionIndex(-1);
};
const confirmQueued = () => {
if (!queued) return;
for (let i = 0; i < queued.count; i++) {
onAddFromBestiary(queued.result);
}
clearInput();
};
const parseNum = (v: string): number | undefined => {
if (v.trim() === "") return undefined;
const n = Number(v);
return Number.isNaN(n) ? undefined : n;
};
const handleAdd = (e: FormEvent) => {
e.preventDefault();
if (browseMode) return;
if (queued) {
confirmQueued();
return;
}
if (nameInput.trim() === "") return;
const opts: { initiative?: number; ac?: number; maxHp?: number } = {};
const init = parseNum(customInit);
const ac = parseNum(customAc);
const maxHp = parseNum(customMaxHp);
if (init !== undefined) opts.initiative = init;
if (ac !== undefined) opts.ac = ac;
if (maxHp !== undefined) opts.maxHp = maxHp;
onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
setNameInput("");
setSuggestions([]);
setPcMatches([]);
clearCustomFields();
};
const handleBrowseSearch = (value: string) => {
setSuggestions(value.length >= 2 ? bestiarySearch(value) : []);
};
const handleAddSearch = (value: string) => {
let newSuggestions: SearchResult[] = [];
let newPcMatches: PlayerCharacter[] = [];
if (value.length >= 2) {
newSuggestions = bestiarySearch(value);
setSuggestions(newSuggestions);
if (playerCharacters && playerCharacters.length > 0) {
const lower = value.toLowerCase();
newPcMatches = playerCharacters.filter((pc) =>
pc.name.toLowerCase().includes(lower),
);
}
setPcMatches(newPcMatches);
} else {
setSuggestions([]);
setPcMatches([]);
}
if (newSuggestions.length > 0 || newPcMatches.length > 0) {
clearCustomFields();
}
if (queued) {
const qKey = creatureKey(queued.result);
const stillVisible = newSuggestions.some((s) => creatureKey(s) === qKey);
if (!stillVisible) {
setQueued(null);
}
}
};
const handleNameChange = (value: string) => {
setNameInput(value);
setSuggestionIndex(-1);
if (browseMode) {
handleBrowseSearch(value);
} else {
handleAddSearch(value);
}
};
const handleClickSuggestion = (result: SearchResult) => {
const key = creatureKey(result);
if (queued && creatureKey(queued.result) === key) {
setQueued({ ...queued, count: queued.count + 1 });
} else {
setQueued({ result, count: 1 });
}
};
const handleEnter = () => {
if (queued) {
confirmQueued();
} else if (suggestionIndex >= 0) {
handleClickSuggestion(suggestions[suggestionIndex]);
}
};
const hasSuggestions =
deferredSuggestions.length > 0 || deferredPcMatches.length > 0;
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!hasSuggestions) 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") {
e.preventDefault();
handleEnter();
} else if (e.key === "Escape") {
dismissSuggestions();
}
};
const handleBrowseKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
setBrowseMode(false);
clearInput();
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);
clearInput();
}
};
const handleBrowseSelect = (result: SearchResult) => {
onViewStatBlock?.(result);
setBrowseMode(false);
clearInput();
};
const toggleBrowseMode = () => {
setBrowseMode((m) => !m);
clearInput();
clearCustomFields();
};
const overflowItems = buildOverflowItems({
onManagePlayers,
onOpenSourceManager,
bestiaryLoaded,
onBulkImport,
bulkImportDisabled,
});
return (
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
<form
onSubmit={handleAdd}
className="relative flex flex-1 items-center gap-2"
>
<div className="flex-1">
<div className="relative max-w-xs">
<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="pr-8"
autoFocus={autoFocus}
/>
{bestiaryLoaded && onViewStatBlock && (
<button
type="button"
tabIndex={-1}
className={cn(
"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
browseMode && "text-accent",
)}
onClick={toggleBrowseMode}
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
aria-label={
browseMode ? "Switch to add mode" : "Browse stat blocks"
}
>
{browseMode ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
)}
{browseMode && deferredSuggestions.length > 0 && (
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
<ul className="max-h-48 overflow-y-auto py-1">
{deferredSuggestions.map((result, i) => (
<li key={creatureKey(result)}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
i === suggestionIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleBrowseSelect(result)}
onMouseEnter={() => setSuggestionIndex(i)}
>
<span>{result.name}</span>
<span className="text-xs text-muted-foreground">
{result.sourceDisplayName}
</span>
</button>
</li>
))}
</ul>
</div>
)}
{!browseMode && hasSuggestions && (
<AddModeSuggestions
nameInput={nameInput}
suggestions={deferredSuggestions}
pcMatches={deferredPcMatches}
suggestionIndex={suggestionIndex}
queued={queued}
onDismiss={dismissSuggestions}
onClear={clearInput}
onClickSuggestion={handleClickSuggestion}
onSetSuggestionIndex={setSuggestionIndex}
onSetQueued={setQueued}
onConfirmQueued={confirmQueued}
onAddFromPlayerCharacter={onAddFromPlayerCharacter}
/>
)}
</div>
{nameInput.length >= 2 && !hasSuggestions && (
</div>
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
<div className="flex items-center gap-2">
<Input
type="text"
@@ -437,96 +575,24 @@ export function ActionBar({
/>
</div>
)}
<Button type="submit" size="sm">
Add
</Button>
<div className="flex items-center gap-0">
{onManagePlayers && (
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
<Button type="submit">Add</Button>
)}
{showRollAllInitiative && onRollAllInitiative && (
<Button
type="button"
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-hover-neutral"
onClick={onManagePlayers}
title="Player characters"
aria-label="Player characters"
className="text-muted-foreground hover:text-hover-action"
onClick={onRollAllInitiative}
disabled={rollAllInitiativeDisabled}
title="Roll all initiative"
aria-label="Roll all initiative"
>
<Users className="h-5 w-5" />
<D20Icon className="h-6 w-6" />
</Button>
)}
{bestiaryLoaded && onViewStatBlock && (
<div ref={viewerRef} className="relative">
<Button
type="button"
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-hover-neutral"
onClick={() => (viewerOpen ? closeViewer() : openViewer())}
title="Browse stat blocks"
aria-label="Browse stat blocks"
>
<Eye className="h-5 w-5" />
</Button>
{viewerOpen && (
<div className="absolute bottom-full right-0 z-50 mb-1 w-64 rounded-md border border-border bg-card shadow-lg">
<div className="p-2">
<Input
ref={viewerInputRef}
type="text"
value={viewerQuery}
onChange={(e) => handleViewerQueryChange(e.target.value)}
onKeyDown={handleViewerKeyDown}
placeholder="Search stat blocks..."
className="w-full"
/>
</div>
{viewerResults.length > 0 && (
<ul className="max-h-48 overflow-y-auto border-t border-border py-1">
{viewerResults.map((result, i) => (
<li key={creatureKey(result)}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
i === viewerIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
}`}
onClick={() => handleViewerSelect(result)}
onMouseEnter={() => setViewerIndex(i)}
>
<span>{result.name}</span>
<span className="text-xs text-muted-foreground">
{result.sourceDisplayName}
</span>
</button>
</li>
))}
</ul>
)}
{viewerQuery.length >= 2 && viewerResults.length === 0 && (
<div className="border-t border-border px-3 py-2 text-sm text-muted-foreground">
No creatures found
</div>
)}
</div>
)}
</div>
)}
{bestiaryLoaded && onBulkImport && (
<Button
type="button"
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-hover-neutral"
onClick={onBulkImport}
disabled={bulkImportDisabled}
title="Bulk import"
aria-label="Bulk import"
>
<Import className="h-5 w-5" />
</Button>
)}
</div>
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
</form>
</div>
);

View File

@@ -28,9 +28,7 @@ export function BulkImportPrompt({
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-sm text-green-400">
All sources loaded
</div>
<Button size="sm" onClick={onDone}>
Done
</Button>
<Button onClick={onDone}>Done</Button>
</div>
);
}
@@ -42,9 +40,7 @@ export function BulkImportPrompt({
Loaded {importState.completed}/{importState.total} sources (
{importState.failed} failed)
</div>
<Button size="sm" onClick={onDone}>
Done
</Button>
<Button onClick={onDone}>Done</Button>
</div>
);
}
@@ -79,11 +75,10 @@ export function BulkImportPrompt({
<div className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-foreground">
Bulk Import Sources
Import All Sources
</h3>
<p className="mt-1 text-xs text-muted-foreground">
Load stat block data for all {totalSources} sources at once. This will
download approximately 12.5 MB of data.
Load stat block data for all {totalSources} sources at once.
</p>
</div>
@@ -103,11 +98,7 @@ export function BulkImportPrompt({
/>
</div>
<Button
size="sm"
onClick={() => onStartImport(baseUrl)}
disabled={isDisabled}
>
<Button onClick={() => onStartImport(baseUrl)} disabled={isDisabled}>
Load All
</Button>
</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
key={color}
type="button"
onClick={() => onChange(color)}
onClick={() => onChange(value === color ? "" : color)}
className={cn(
"h-8 w-8 rounded-full transition-all",
value === color

View File

@@ -13,8 +13,8 @@ interface CreatePlayerModalProps {
name: string,
ac: number,
maxHp: number,
color: string,
icon: string,
color: string | undefined,
icon: string | undefined,
) => void;
playerCharacter?: PlayerCharacter;
}
@@ -40,14 +40,14 @@ export function CreatePlayerModal({
setName(playerCharacter.name);
setAc(String(playerCharacter.ac));
setMaxHp(String(playerCharacter.maxHp));
setColor(playerCharacter.color);
setIcon(playerCharacter.icon);
setColor(playerCharacter.color ?? "");
setIcon(playerCharacter.icon ?? "");
} else {
setName("");
setAc("10");
setMaxHp("10");
setColor("blue");
setIcon("sword");
setColor("");
setIcon("");
}
setError("");
}
@@ -81,7 +81,7 @@ export function CreatePlayerModal({
setError("Max HP must be at least 1");
return;
}
onSave(trimmed, acNum, hpNum, color, icon);
onSave(trimmed, acNum, hpNum, color || undefined, icon || undefined);
onClose();
};
@@ -100,13 +100,14 @@ export function CreatePlayerModal({
<h2 className="text-lg font-semibold text-foreground">
{isEdit ? "Edit Player" : "Create Player"}
</h2>
<button
type="button"
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="text-muted-foreground hover:text-foreground transition-colors"
className="text-muted-foreground"
>
<X size={20} />
</button>
</Button>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">

View File

@@ -6,7 +6,6 @@ import {
useRef,
useState,
} from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
interface HpAdjustPopoverProps {
@@ -109,30 +108,26 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
}}
onKeyDown={handleKeyDown}
/>
<Button
<button
type="button"
variant="ghost"
size="icon"
disabled={!isValid}
className="h-7 w-7 shrink-0 text-red-400 hover:bg-red-950 hover:text-red-300"
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-red-400 transition-colors hover:bg-red-950 hover:text-red-300 disabled:pointer-events-none disabled:opacity-50"
onClick={() => applyDelta(-1)}
title="Apply damage"
aria-label="Apply damage"
>
<Sword size={14} />
</Button>
<Button
</button>
<button
type="button"
variant="ghost"
size="icon"
disabled={!isValid}
className="h-7 w-7 shrink-0 text-emerald-400 hover:bg-emerald-950 hover:text-emerald-300"
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-emerald-400 transition-colors hover:bg-emerald-950 hover:text-emerald-300 disabled:pointer-events-none disabled:opacity-50"
onClick={() => applyDelta(1)}
title="Apply healing"
aria-label="Apply healing"
>
<Heart size={14} />
</Button>
</button>
</div>
</div>
);

View File

@@ -19,7 +19,7 @@ export function IconGrid({ value, onChange }: IconGridProps) {
<button
key={iconId}
type="button"
onClick={() => onChange(iconId)}
onClick={() => onChange(value === iconId ? "" : iconId)}
className={cn(
"flex h-9 w-9 items-center justify-center rounded-md transition-all",
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 {
PlayerCharacter,
PlayerCharacterId,
PlayerIcon,
} from "@initiative/domain";
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
import { Pencil, Plus, Trash2, X } from "lucide-react";
import { useEffect } from "react";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
@@ -52,19 +48,20 @@ export function PlayerManagement({
<h2 className="text-lg font-semibold text-foreground">
Player Characters
</h2>
<button
type="button"
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="text-muted-foreground hover:text-foreground transition-colors"
className="text-muted-foreground"
>
<X size={20} />
</button>
</Button>
</div>
{characters.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-8 text-center">
<p className="text-muted-foreground">No player characters yet</p>
<Button onClick={onCreate} size="sm">
<Button onClick={onCreate}>
<Plus size={16} />
Create your first player character
</Button>
@@ -72,13 +69,12 @@ export function PlayerManagement({
) : (
<div className="flex flex-col gap-1">
{characters.map((pc) => {
const Icon = PLAYER_ICON_MAP[pc.icon as PlayerIcon];
const color =
PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX];
const Icon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
const color = pc.color ? PLAYER_COLOR_HEX[pc.color] : undefined;
return (
<div
key={pc.id}
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-background/50"
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-hover-neutral-bg"
>
{Icon && (
<Icon size={18} style={{ color }} className="shrink-0" />
@@ -92,25 +88,27 @@ export function PlayerManagement({
<span className="text-xs tabular-nums text-muted-foreground">
HP {pc.maxHp}
</span>
<button
type="button"
<Button
variant="ghost"
size="icon-sm"
onClick={() => onEdit(pc)}
className="text-muted-foreground hover:text-foreground transition-colors"
className="text-muted-foreground"
title="Edit"
>
<Pencil size={14} />
</button>
</Button>
<ConfirmButton
icon={<Trash2 size={14} />}
label="Delete player character"
onConfirm={() => onDelete(pc.id)}
className="h-6 w-6 text-muted-foreground"
size="icon-sm"
className="text-muted-foreground"
/>
</div>
);
})}
<div className="mt-2 flex justify-end">
<Button onClick={onCreate} size="sm" variant="ghost">
<Button onClick={onCreate} variant="ghost">
<Plus size={16} />
Add
</Button>

View File

@@ -88,11 +88,7 @@ export function SourceFetchPrompt({
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={handleFetch}
disabled={status === "fetching" || !url}
>
<Button onClick={handleFetch} disabled={status === "fetching" || !url}>
{status === "fetching" ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
@@ -104,7 +100,6 @@ export function SourceFetchPrompt({
<span className="text-xs text-muted-foreground">or</span>
<Button
size="sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={status === "fetching"}

View File

@@ -1,5 +1,5 @@
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 * as bestiaryCache from "../adapters/bestiary-cache.js";
import { Button } from "./ui/button.js";
@@ -10,6 +10,16 @@ interface SourceManagerProps {
export function SourceManager({ onCacheCleared }: SourceManagerProps) {
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 cached = await bestiaryCache.getCachedSources();
@@ -21,18 +31,20 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
}, [loadSources]);
const handleClearSource = async (sourceCode: string) => {
applyOptimistic({ type: "remove", sourceCode });
await bestiaryCache.clearSource(sourceCode);
await loadSources();
onCacheCleared();
};
const handleClearAll = async () => {
applyOptimistic({ type: "clear" });
await bestiaryCache.clearAll();
await loadSources();
onCacheCleared();
};
if (sources.length === 0) {
if (optimisticSources.length === 0) {
return (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<Database className="h-8 w-8 text-muted-foreground" />
@@ -48,7 +60,6 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
Cached Sources
</span>
<Button
size="sm"
variant="outline"
className="hover:text-hover-destructive hover:border-hover-destructive"
onClick={handleClearAll}
@@ -58,7 +69,7 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
</Button>
</div>
<ul className="flex flex-col gap-1">
{sources.map((source) => (
{optimisticSources.map((source) => (
<li
key={source.sourceCode}
className="flex items-center justify-between rounded-md border border-border px-3 py-2"

View File

@@ -7,7 +7,9 @@ import type { BulkImportState } from "../hooks/use-bulk-import.js";
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
import { BulkImportPrompt } from "./bulk-import-prompt.js";
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
import { SourceManager } from "./source-manager.js";
import { StatBlock } from "./stat-block.js";
import { Button } from "./ui/button.js";
interface StatBlockPanelProps {
creatureId: CreatureId | null;
@@ -20,8 +22,8 @@ interface StatBlockPanelProps {
) => Promise<void>;
refreshCache: () => Promise<void>;
panelRole: "browse" | "pinned";
isFolded: boolean;
onToggleFold: () => void;
isCollapsed: boolean;
onToggleCollapse: () => void;
onPin: () => void;
onUnpin: () => void;
showPinButton: boolean;
@@ -31,6 +33,7 @@ interface StatBlockPanelProps {
bulkImportState?: BulkImportState;
onStartBulkImport?: (baseUrl: string) => void;
onBulkImportDone?: () => void;
sourceManagerMode?: boolean;
}
function extractSourceCode(cId: CreatureId): string {
@@ -39,23 +42,23 @@ function extractSourceCode(cId: CreatureId): string {
return cId.slice(0, colonIndex).toUpperCase();
}
function FoldedTab({
function CollapsedTab({
creatureName,
side,
onToggleFold,
onToggleCollapse,
}: {
creatureName: string;
side: "left" | "right";
onToggleFold: () => void;
onToggleCollapse: () => void;
}) {
return (
<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 ${
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">
{creatureName}
@@ -67,13 +70,13 @@ function FoldedTab({
function PanelHeader({
panelRole,
showPinButton,
onToggleFold,
onToggleCollapse,
onPin,
onUnpin,
}: {
panelRole: "browse" | "pinned";
showPinButton: boolean;
onToggleFold: () => void;
onToggleCollapse: () => void;
onPin: () => void;
onUnpin: () => void;
}) {
@@ -81,36 +84,39 @@ function PanelHeader({
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex items-center gap-1">
{panelRole === "browse" && (
<button
type="button"
onClick={onToggleFold}
className="text-muted-foreground hover:text-hover-neutral"
aria-label="Fold stat block panel"
<Button
variant="ghost"
size="icon-sm"
onClick={onToggleCollapse}
className="text-muted-foreground"
aria-label="Collapse stat block panel"
>
<PanelRightClose className="h-4 w-4" />
</button>
</Button>
)}
</div>
<div className="flex items-center gap-1">
{panelRole === "browse" && showPinButton && (
<button
type="button"
<Button
variant="ghost"
size="icon-sm"
onClick={onPin}
className="text-muted-foreground hover:text-hover-neutral"
className="text-muted-foreground"
aria-label="Pin creature"
>
<Pin className="h-4 w-4" />
</button>
</Button>
)}
{panelRole === "pinned" && (
<button
type="button"
<Button
variant="ghost"
size="icon-sm"
onClick={onUnpin}
className="text-muted-foreground hover:text-hover-neutral"
className="text-muted-foreground"
aria-label="Unpin creature"
>
<PinOff className="h-4 w-4" />
</button>
</Button>
)}
</div>
</div>
@@ -118,48 +124,48 @@ function PanelHeader({
}
function DesktopPanel({
isFolded,
isCollapsed,
side,
creatureName,
panelRole,
showPinButton,
onToggleFold,
onToggleCollapse,
onPin,
onUnpin,
children,
}: {
isFolded: boolean;
isCollapsed: boolean;
side: "left" | "right";
creatureName: string;
panelRole: "browse" | "pinned";
showPinButton: boolean;
onToggleFold: () => void;
onToggleCollapse: () => void;
onPin: () => void;
onUnpin: () => void;
children: ReactNode;
}) {
const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l";
const foldedTranslate =
const collapsedTranslate =
side === "right"
? "translate-x-[calc(100%-40px)]"
: "translate-x-[calc(-100%+40px)]";
return (
<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 ? (
<FoldedTab
{isCollapsed ? (
<CollapsedTab
creatureName={creatureName}
side={side}
onToggleFold={onToggleFold}
onToggleCollapse={onToggleCollapse}
/>
) : (
<>
<PanelHeader
panelRole={panelRole}
showPinButton={showPinButton}
onToggleFold={onToggleFold}
onToggleCollapse={onToggleCollapse}
onPin={onPin}
onUnpin={onUnpin}
/>
@@ -195,14 +201,15 @@ function MobileDrawer({
{...handlers}
>
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<button
type="button"
<Button
variant="ghost"
size="icon-sm"
onClick={onDismiss}
className="text-muted-foreground hover:text-hover-neutral"
aria-label="Fold stat block panel"
className="text-muted-foreground"
aria-label="Collapse stat block panel"
>
<PanelRightClose className="h-4 w-4" />
</button>
</Button>
</div>
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
{children}
@@ -220,8 +227,8 @@ export function StatBlockPanel({
uploadAndCacheSource,
refreshCache,
panelRole,
isFolded,
onToggleFold,
isCollapsed,
onToggleCollapse,
onPin,
onUnpin,
showPinButton,
@@ -231,6 +238,7 @@ export function StatBlockPanel({
bulkImportState,
onStartBulkImport,
onBulkImportDone,
sourceManagerMode,
}: StatBlockPanelProps) {
const [isDesktop, setIsDesktop] = useState(
() => window.matchMedia("(min-width: 1024px)").matches,
@@ -264,7 +272,7 @@ export function StatBlockPanel({
});
}, [creatureId, creature, isSourceCached]);
if (!creatureId && !bulkImportMode) return null;
if (!creatureId && !bulkImportMode && !sourceManagerMode) return null;
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
@@ -274,6 +282,10 @@ export function StatBlockPanel({
};
const renderContent = () => {
if (sourceManagerMode) {
return <SourceManager onCacheCleared={refreshCache} />;
}
if (
bulkImportMode &&
bulkImportState &&
@@ -319,17 +331,22 @@ export function StatBlockPanel({
};
const creatureName =
creature?.name ?? (bulkImportMode ? "Bulk Import" : "Creature");
creature?.name ??
(sourceManagerMode
? "Sources"
: bulkImportMode
? "Import All Sources"
: "Creature");
if (isDesktop) {
return (
<DesktopPanel
isFolded={isFolded}
isCollapsed={isCollapsed}
side={side}
creatureName={creatureName}
panelRole={panelRole}
showPinButton={showPinButton}
onToggleFold={onToggleFold}
onToggleCollapse={onToggleCollapse}
onPin={onPin}
onUnpin={onUnpin}
>

View File

@@ -1,6 +1,7 @@
import { X } from "lucide-react";
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { Button } from "./ui/button.js";
interface ToastProps {
message: string;
@@ -22,7 +23,7 @@ export function Toast({
}, [autoDismissMs, onDismiss]);
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">
<span className="text-sm text-foreground">{message}</span>
{progress !== undefined && (
@@ -33,13 +34,14 @@ export function Toast({
/>
</div>
)}
<button
type="button"
<Button
variant="ghost"
size="icon-sm"
onClick={onDismiss}
className="text-muted-foreground hover:text-hover-neutral"
className="text-muted-foreground"
>
<X className="h-3 w-3" />
</button>
</Button>
</div>
</div>,
document.body,

View File

@@ -1,6 +1,5 @@
import type { Encounter } from "@initiative/domain";
import { Library, StepBack, StepForward, Trash2 } from "lucide-react";
import { D20Icon } from "./d20-icon";
import { StepBack, StepForward, Trash2 } from "lucide-react";
import { Button } from "./ui/button";
import { ConfirmButton } from "./ui/confirm-button";
@@ -9,8 +8,6 @@ interface TurnNavigationProps {
onAdvanceTurn: () => void;
onRetreatTurn: () => void;
onClearEncounter: () => void;
onRollAllInitiative: () => void;
onOpenSourceManager: () => void;
}
export function TurnNavigation({
@@ -18,8 +15,6 @@ export function TurnNavigation({
onAdvanceTurn,
onRetreatTurn,
onClearEncounter,
onRollAllInitiative,
onOpenSourceManager,
}: TurnNavigationProps) {
const hasCombatants = encounter.combatants.length > 0;
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
@@ -28,6 +23,7 @@ export function TurnNavigation({
return (
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
<Button
variant="outline"
size="icon"
onClick={onRetreatTurn}
disabled={!hasCombatants || isAtStart}
@@ -49,27 +45,6 @@ export function TurnNavigation({
</div>
<div className="flex flex-shrink-0 items-center gap-3">
<div className="flex items-center gap-0">
<Button
variant="ghost"
size="icon"
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>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-hover-neutral"
onClick={onOpenSourceManager}
title="Manage cached sources"
aria-label="Manage cached sources"
>
<Library className="h-5 w-5" />
</Button>
<ConfirmButton
icon={<Trash2 className="h-5 w-5" />}
label="Clear encounter"
@@ -77,8 +52,8 @@ export function TurnNavigation({
disabled={!hasCombatants}
className="text-muted-foreground"
/>
</div>
<Button
variant="outline"
size="icon"
onClick={onAdvanceTurn}
disabled={!hasCombatants}

View File

@@ -13,9 +13,9 @@ const buttonVariants = cva(
ghost: "hover:bg-hover-neutral-bg hover:text-hover-neutral",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 px-3 text-xs",
default: "h-8 px-3 text-xs",
icon: "h-8 w-8",
"icon-sm": "h-6 w-6",
},
},
defaultVariants: {

View File

@@ -13,6 +13,7 @@ interface ConfirmButtonProps {
readonly onConfirm: () => void;
readonly icon: ReactElement;
readonly label: string;
readonly size?: "icon" | "icon-sm";
readonly className?: string;
readonly disabled?: boolean;
}
@@ -23,6 +24,7 @@ export function ConfirmButton({
onConfirm,
icon,
label,
size = "icon",
className,
disabled,
}: ConfirmButtonProps) {
@@ -94,7 +96,7 @@ export function ConfirmButton({
<div ref={wrapperRef} className="inline-flex">
<Button
variant="ghost"
size="icon"
size={size}
className={cn(
className,
isConfirming

View File

@@ -0,0 +1,72 @@
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>
);
}

View File

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

View File

@@ -371,9 +371,20 @@ export function useEncounter() {
[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 {
encounter,
events,
isEmpty,
hasCreatureCombatants,
canRollAllInitiative,
advanceTurn,
retreatTurn,
addCombatant,

View File

@@ -26,8 +26,8 @@ interface EditFields {
readonly name?: string;
readonly ac?: number;
readonly maxHp?: number;
readonly color?: string;
readonly icon?: string;
readonly color?: string | null;
readonly icon?: string | null;
}
export function usePlayerCharacters() {
@@ -51,7 +51,13 @@ export function usePlayerCharacters() {
}, []);
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 result = createPlayerCharacterUseCase(
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

@@ -16,7 +16,7 @@
--color-hover-neutral: var(--color-primary);
--color-hover-action: var(--color-primary);
--color-hover-destructive: var(--color-destructive);
--color-hover-neutral-bg: var(--color-card);
--color-hover-neutral-bg: oklch(0.623 0.214 259 / 0.15);
--color-hover-action-bg: var(--color-muted);
--color-hover-destructive-bg: transparent;
--radius-sm: 0.25rem;

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 {
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
return null;
@@ -35,10 +42,8 @@ function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
entry.maxHp < 1
)
return null;
if (typeof entry.color !== "string" || !VALID_PLAYER_COLORS.has(entry.color))
return null;
if (typeof entry.icon !== "string" || !VALID_PLAYER_ICONS.has(entry.icon))
return null;
if (!isValidOptionalMember(entry.color, VALID_PLAYER_COLORS)) return null;
if (!isValidOptionalMember(entry.icon, VALID_PLAYER_ICONS)) return null;
return {
id: playerCharacterId(entry.id),

View File

@@ -0,0 +1,176 @@
---
date: "2026-03-13T14:39:15.661886+00:00"
git_commit: 75778884bd1be7d135b2f5ea9b8a8e77a0149f7b
branch: main
topic: "Action Bars Setup — Top Bar and Bottom Bar Buttons"
tags: [research, codebase, action-bar, turn-navigation, layout, buttons]
status: complete
---
# Research: Action Bars Setup — Top Bar and Bottom Bar Buttons
## Research Question
How are the top and bottom action bars set up, what buttons do they contain, and how are their actions wired?
## Summary
The application has two primary bar components that frame the encounter tracker UI:
1. **Top bar**`TurnNavigation` (`turn-navigation.tsx`) — turn controls, round/combatant display, and encounter-wide actions.
2. **Bottom bar**`ActionBar` (`action-bar.tsx`) — combatant input, bestiary search, stat block browsing, bulk import, and player character management.
Both bars share the same visual container styling (`flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3`). They are laid out in `App.tsx` within a flex column, with a scrollable combatant list between them. When the encounter is empty, only the ActionBar is shown (centered in the viewport); the TurnNavigation appears with an animation when the first combatant is added.
## Detailed Findings
### Layout Structure (`App.tsx:243-344`)
The bars live inside a `max-w-2xl` centered column:
```
┌──────────────────────────────────┐
│ TurnNavigation (pt-8, shrink-0) │ ← top bar, conditionally shown
├──────────────────────────────────┤
│ SourceManager (optional inline) │ ← toggled by Library button in top bar
├──────────────────────────────────┤
│ Combatant list (flex-1, │ ← scrollable
│ overflow-y-auto) │
├──────────────────────────────────┤
│ ActionBar (pb-8, shrink-0) │ ← bottom bar
└──────────────────────────────────┘
```
**Empty state**: When `encounter.combatants.length === 0`, the top bar is hidden and the ActionBar is vertically centered in a `flex items-center justify-center` wrapper with `pb-[15%]` offset. It receives `autoFocus` in this state.
**Animation** (`useActionBarAnimation`, `App.tsx:30-66`): Manages transitions between empty and populated states:
- Empty → populated: ActionBar plays `animate-settle-to-bottom`, TurnNavigation plays `animate-slide-down-in`.
- Populated → empty: ActionBar plays `animate-rise-to-center`, TurnNavigation plays `animate-slide-up-out` (with `absolute` positioning during exit).
The `showTopBar` flag is `true` when either combatants exist or the top bar exit animation is still running.
### Top Bar — TurnNavigation (`turn-navigation.tsx`)
**Props interface** (`turn-navigation.tsx:7-14`):
- `encounter: Encounter` — full encounter state
- `onAdvanceTurn`, `onRetreatTurn` — turn navigation callbacks
- `onClearEncounter` — destructive clear with confirmation
- `onRollAllInitiative` — rolls initiative for all combatants
- `onOpenSourceManager` — toggles source manager panel
**Layout**: LeftCenterRight structure:
```
[ ◀ Prev ] | [ R1 Active Combatant Name ] | [ 🎲 📚 🗑 ] [ Next ▶ ]
```
**Buttons (left to right)**:
| # | Icon | Component | Variant | Action | Disabled when |
|---|------|-----------|---------|--------|---------------|
| 1 | `StepBack` | `Button` | default | `onRetreatTurn` | No combatants OR at round 1 index 0 |
| 2 | `D20Icon` | `Button` | ghost | `onRollAllInitiative` | Never |
| 3 | `Library` | `Button` | ghost | `onOpenSourceManager` | Never |
| 4 | `Trash2` | `ConfirmButton` | — | `onClearEncounter` | No combatants |
| 5 | `StepForward` | `Button` | default | `onAdvanceTurn` | No combatants |
**Center section** (`turn-navigation.tsx:40-49`): Displays a round badge (`R{n}` in a `rounded-full bg-muted` span) and the active combatant's name (truncated). Falls back to "No combatants" in muted text.
**Button grouping**: Buttons 2-4 are grouped in a `gap-0` div (tight spacing), while button 5 (Next) is separated by the outer `gap-3`.
**Wiring in App.tsx** (`App.tsx:251-258`):
- `onAdvanceTurn``advanceTurn` from `useEncounter()`
- `onRetreatTurn``retreatTurn` from `useEncounter()`
- `onClearEncounter``clearEncounter` from `useEncounter()`
- `onRollAllInitiative``handleRollAllInitiative` → calls `rollAllInitiativeUseCase(makeStore(), rollDice, getCreature)`
- `onOpenSourceManager` → toggles `sourceManagerOpen` state
### Bottom Bar — ActionBar (`action-bar.tsx`)
**Props interface** (`action-bar.tsx:20-36`):
- `onAddCombatant` — adds custom combatant with optional init/AC/maxHP
- `onAddFromBestiary` — adds creature from search result
- `bestiarySearch` — search function returning `SearchResult[]`
- `bestiaryLoaded` — whether bestiary index is loaded
- `onViewStatBlock` — opens stat block panel for a creature
- `onBulkImport` — triggers bulk source import mode
- `bulkImportDisabled` — disables import button during loading
- `inputRef` — external ref to the name input
- `playerCharacters` — list of player characters for quick-add
- `onAddFromPlayerCharacter` — adds a player character to encounter
- `onManagePlayers` — opens player management modal
- `autoFocus` — auto-focuses input (used in empty state)
**Layout**: Form with input, contextual fields, submit button, and action icons:
```
[ + Add combatants... ] [ Init ] [ AC ] [ MaxHP ] [ Add ] [ 👥 👁 📥 ]
```
The Init/AC/MaxHP fields only appear when the input has 2+ characters and no bestiary suggestions are showing.
**Buttons (left to right)**:
| # | Icon | Component | Variant | Action | Condition |
|---|------|-----------|---------|--------|-----------|
| 1 | — | `Button` | sm | Form submit → `handleAdd` | Always shown |
| 2 | `Users` | `Button` | ghost | `onManagePlayers` | Only if `onManagePlayers` provided |
| 3 | `Eye` | `Button` | ghost | Toggle stat block viewer dropdown | Only if `bestiaryLoaded && onViewStatBlock` |
| 4 | `Import` | `Button` | ghost | `onBulkImport` | Only if `bestiaryLoaded && onBulkImport` |
**Button grouping**: Buttons 2-4 are grouped in a `gap-0` div, mirroring the top bar's icon button grouping.
**Suggestion dropdown** (`action-bar.tsx:267-410`): Opens above the input when 2+ chars are typed and results exist. Contains:
- A "Add as custom" escape row at the top (with `Esc` keyboard hint)
- **Players section**: Lists matching player characters with colored icons; clicking adds them directly via `onAddFromPlayerCharacter`
- **Bestiary section**: Lists search results; clicking queues a creature. Queued creatures show:
- `Minus` button — decrements count (removes queue at 0)
- Count badge — current queued count
- `Plus` button — increments count
- `Check` button — confirms and adds all queued copies
**Stat block viewer dropdown** (`action-bar.tsx:470-513`): A separate search dropdown anchored to the Eye button. Has its own input, search results, and keyboard navigation. Selecting a result calls `onViewStatBlock`.
**Keyboard handling** (`action-bar.tsx:168-186`):
- Arrow Up/Down — navigate suggestion list
- Enter — queue selected suggestion or confirm queued batch
- Escape — clear suggestions and queue
**Wiring in App.tsx** (`App.tsx:269-282` and `328-340`):
- `onAddCombatant``addCombatant` from `useEncounter()`
- `onAddFromBestiary``handleAddFromBestiary``addFromBestiary` from `useEncounter()`
- `bestiarySearch``search` from `useBestiary()`
- `onViewStatBlock``handleViewStatBlock` → constructs `CreatureId` and sets `selectedCreatureId`
- `onBulkImport``handleBulkImport` → sets `bulkImportMode` and clears selection
- `onAddFromPlayerCharacter``addFromPlayerCharacter` from `useEncounter()`
- `onManagePlayers` → opens `managementOpen` state (shows `PlayerManagement` modal)
### Shared UI Primitives
**`Button`** (`ui/button.tsx`): CVA-based component with variants (`default`, `outline`, `ghost`) and sizes (`default`, `sm`, `icon`). Both bars use `size="icon"` with `variant="ghost"` for their icon button clusters, and `size="icon"` with default variant for the primary navigation buttons (Prev/Next in top bar).
**`ConfirmButton`** (`ui/confirm-button.tsx`): Two-click destructive action button. First click shows a red pulsing confirmation state with a Check icon; second click fires `onConfirm`. Auto-reverts after 5 seconds. Supports Escape and click-outside cancellation. Used for Clear Encounter in the top bar.
### Hover Color Convention
Both bars use consistent hover color classes on their ghost icon buttons:
- `hover:text-hover-action` — used on the D20 (roll initiative) button, suggesting an action/accent color
- `hover:text-hover-neutral` — used on Library, Users, Eye, Import buttons, suggesting a neutral/informational color
## Code References
- `apps/web/src/components/turn-navigation.tsx` — Top bar component (93 lines)
- `apps/web/src/components/action-bar.tsx` — Bottom bar component (533 lines)
- `apps/web/src/App.tsx:30-66``useActionBarAnimation` hook for bar transitions
- `apps/web/src/App.tsx:243-344` — Layout structure with both bars
- `apps/web/src/components/ui/button.tsx` — Shared Button component
- `apps/web/src/components/ui/confirm-button.tsx` — Two-step confirmation button
- `apps/web/src/components/d20-icon.tsx` — Custom D20 dice SVG icon
## Architecture Documentation
The bars follow the app's adapter-layer convention: they are pure presentational React components that receive all behavior via callback props. No business logic lives in either bar — they delegate to handlers defined in `App.tsx`, which in turn call use-case functions from the application layer or manipulate local UI state.
Both bars are rendered twice in `App.tsx` (once in the empty-state branch, once in the populated branch) rather than being conditionally repositioned, which simplifies the animation logic.
The `ActionBar` is the more complex of the two, managing multiple pieces of local state (input value, suggestions, queued creatures, custom fields, stat block viewer) while `TurnNavigation` is fully stateless — all its data comes from the `encounter` prop.

View File

@@ -0,0 +1,188 @@
---
date: "2026-03-13T15:35:07.699570+00:00"
git_commit: bd398080008349b47726d0016f4b03587f453833
branch: main
topic: "CSS class usage, button categorization, and hover effects across all components"
tags: [research, codebase, css, tailwind, buttons, hover, ui]
status: complete
---
# Research: CSS Class Usage, Button Categorization, and Hover Effects
## Research Question
How are CSS classes used across all components? How are buttons categorized — are there primary and secondary buttons? What hover effects exist, and are they unified?
## Summary
The project uses **Tailwind CSS v4** with a custom dark theme defined in `index.css` via `@theme`. All class merging goes through a `cn()` utility (clsx + tailwind-merge). Buttons are built on a shared `Button` component using **class-variance-authority (CVA)** with three variants: **default** (primary), **outline**, and **ghost**. Hover effects are partially unified through semantic color tokens (`hover-neutral`, `hover-action`, `hover-destructive`) defined in the theme, but several components use **one-off hardcoded hover colors** that bypass the token system.
## Detailed Findings
### Theme System (`index.css`)
All colors are defined as CSS custom properties via Tailwind v4's `@theme` directive (`index.css:3-26`):
| Token | Value | Purpose |
|---|---|---|
| `--color-background` | `#0f172a` | Page background |
| `--color-foreground` | `#e2e8f0` | Default text |
| `--color-muted` | `#64748b` | Subdued elements |
| `--color-muted-foreground` | `#94a3b8` | Secondary text |
| `--color-card` | `#1e293b` | Card/panel surfaces |
| `--color-border` | `#334155` | Borders |
| `--color-primary` | `#3b82f6` | Primary actions (blue) |
| `--color-accent` | `#3b82f6` | Accent (same as primary) |
| `--color-destructive` | `#ef4444` | Destructive actions (red) |
**Hover tokens** (semantic layer for hover states):
| Token | Resolves to | Usage |
|---|---|---|
| `hover-neutral` | `primary` (blue) | Text color on neutral hover |
| `hover-action` | `primary` (blue) | Text color on action hover |
| `hover-destructive` | `destructive` (red) | Text color on destructive hover |
| `hover-neutral-bg` | `card` (slate) | Background on neutral hover |
| `hover-action-bg` | `muted` | Background on action hover |
| `hover-destructive-bg` | `transparent` | Background on destructive hover |
### Button Component (`components/ui/button.tsx`)
Uses CVA with three variants and three sizes:
**Variants:**
| Variant | Base styles | Hover |
|---|---|---|
| `default` (primary) | `bg-primary text-primary-foreground` | `hover:bg-primary/90` |
| `outline` | `border border-border bg-transparent` | `hover:bg-hover-neutral-bg hover:text-hover-neutral` |
| `ghost` | (no background/border) | `hover:bg-hover-neutral-bg hover:text-hover-neutral` |
**Sizes:**
| Size | Classes |
|---|---|
| `default` | `h-9 px-4 py-2` |
| `sm` | `h-8 px-3 text-xs` |
| `icon` | `h-8 w-8` |
All variants share: `rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50`.
There is **no "secondary" variant** — the outline variant is the closest equivalent.
### Composite Button Components
**ConfirmButton** (`components/ui/confirm-button.tsx`):
- Wraps `Button variant="ghost" size="icon"`
- Default state: `hover:text-hover-destructive` (uses token)
- Confirming state: `bg-destructive text-primary-foreground animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground`
**OverflowMenu** (`components/ui/overflow-menu.tsx`):
- Trigger: `Button variant="ghost" size="icon"` with `text-muted-foreground hover:text-hover-neutral`
- Menu items: raw `<button>` elements with `hover:bg-muted/20` (**not using the token system**)
### Button Usage Across Components
| Component | Button type | Variant/Style |
|---|---|---|
| `action-bar.tsx:556` | `<Button type="submit">` | default (primary) — "Add" |
| `action-bar.tsx:561` | `<Button type="button">` | default (primary) — "Roll all" |
| `turn-navigation.tsx:25,54` | `<Button size="icon">` | default — prev/next turn |
| `turn-navigation.tsx:47` | `<ConfirmButton>` | ghost+destructive — clear encounter |
| `source-fetch-prompt.tsx:91` | `<Button size="sm">` | default — "Load" |
| `source-fetch-prompt.tsx:106` | `<Button size="sm" variant="outline">` | outline — "Upload file" |
| `bulk-import-prompt.tsx:31,45,106` | `<Button size="sm">` | default — "Done"/"Load All" |
| `source-manager.tsx:50` | `<Button size="sm" variant="outline">` | outline — "Clear all" |
| `hp-adjust-popover.tsx:112` | `<Button variant="ghost" size="icon">` | ghost + custom red — damage |
| `hp-adjust-popover.tsx:124` | `<Button variant="ghost" size="icon">` | ghost + custom green — heal |
| `player-management.tsx:67` | `<Button>` | default — "Create first player" |
| `player-management.tsx:113` | `<Button variant="ghost">` | ghost — "Add player" |
| `create-player-modal.tsx:177` | `<Button variant="ghost">` | ghost — "Cancel" |
| `create-player-modal.tsx:180` | `<Button type="submit">` | default — "Save"/"Create" |
| `combatant-row.tsx:625` | `<ConfirmButton>` | ghost+destructive — remove combatant |
**Raw `<button>` elements** (not using the Button component):
- `action-bar.tsx` — suggestion items, count increment/decrement, browse toggle, custom add (all inline-styled)
- `combatant-row.tsx` — editable name, HP display, AC, initiative, concentration toggle
- `stat-block-panel.tsx` — fold/close/pin/unpin buttons
- `condition-picker.tsx` — condition items
- `condition-tags.tsx` — condition tags, add condition button
- `toast.tsx` — dismiss button
- `player-management.tsx` — close modal, edit player
- `create-player-modal.tsx` — close modal
- `color-palette.tsx` — color swatches
- `icon-grid.tsx` — icon options
### Hover Effects Inventory
**Using semantic tokens (unified):**
| Hover class | Meaning | Used in |
|---|---|---|
| `hover:bg-hover-neutral-bg` | Neutral background highlight | button.tsx (outline/ghost), action-bar.tsx, condition-picker.tsx, condition-tags.tsx |
| `hover:text-hover-neutral` | Text turns primary blue | button.tsx (outline/ghost), action-bar.tsx, combatant-row.tsx, stat-block-panel.tsx, ac-shield.tsx, toast.tsx, overflow-menu.tsx, condition-tags.tsx |
| `hover:text-hover-action` | Action text (same as neutral) | action-bar.tsx (overflow trigger) |
| `hover:text-hover-destructive` | Destructive text turns red | confirm-button.tsx, source-manager.tsx |
| `hover:bg-hover-destructive-bg` | Destructive background (transparent) | source-manager.tsx |
**One-off / hardcoded hover colors (NOT using tokens):**
| Hover class | Used in | Context |
|---|---|---|
| `hover:bg-primary/90` | button.tsx (default variant) | Primary button darken |
| `hover:bg-accent/20` | action-bar.tsx | Suggestion highlight, custom add |
| `hover:bg-accent/40` | action-bar.tsx | Count +/- buttons, confirm queued |
| `hover:bg-muted/20` | overflow-menu.tsx | Menu item highlight |
| `hover:bg-red-950` | hp-adjust-popover.tsx | Damage button |
| `hover:text-red-300` | hp-adjust-popover.tsx | Damage button text |
| `hover:bg-emerald-950` | hp-adjust-popover.tsx | Heal button |
| `hover:text-emerald-300` | hp-adjust-popover.tsx | Heal button text |
| `hover:text-foreground` | player-management.tsx, create-player-modal.tsx, icon-grid.tsx | Close/edit buttons |
| `hover:bg-background/50` | player-management.tsx | Player row hover |
| `hover:bg-card` | icon-grid.tsx | Icon option hover |
| `hover:border-hover-destructive` | source-manager.tsx | Clear all button border |
| `hover:scale-110` | color-palette.tsx | Color swatch enlarge |
| `hover:bg-destructive` | confirm-button.tsx (confirming state) | Maintain red bg on hover |
| `hover:text-primary-foreground` | confirm-button.tsx (confirming state) | Maintain white text on hover |
### Hover unification assessment
The hover token system (`hover-neutral`, `hover-action`, `hover-destructive`) provides a consistent pattern for the most common interactions. The `Button` component's outline and ghost variants use these tokens, and many inline buttons in action-bar, combatant-row, stat-block-panel, and condition components also use them.
However, there are notable gaps:
1. **HP adjust popover** uses hardcoded red/green colors (`red-950`, `emerald-950`) instead of tokens
2. **Overflow menu items** use `hover:bg-muted/20` instead of `hover:bg-hover-neutral-bg`
3. **Player management modals** use `hover:text-foreground` and `hover:bg-background/50` instead of the semantic tokens
4. **Action-bar suggestion items** use `hover:bg-accent/20` and `hover:bg-accent/40` — accent-specific patterns not in the token system
5. **Icon grid** and **color palette** use their own hover patterns (`hover:bg-card`, `hover:scale-110`)
## Code References
- `apps/web/src/index.css:3-26` — Theme color definitions including hover tokens
- `apps/web/src/components/ui/button.tsx:1-38` — Button component with CVA variants
- `apps/web/src/components/ui/confirm-button.tsx:93-115` — ConfirmButton with destructive hover states
- `apps/web/src/components/ui/overflow-menu.tsx:38-72` — OverflowMenu with non-token hover
- `apps/web/src/components/hp-adjust-popover.tsx:117-129` — Hardcoded red/green hover colors
- `apps/web/src/components/action-bar.tsx:80-188` — Mixed token and accent-based hovers
- `apps/web/src/components/combatant-row.tsx:147-629` — Inline buttons with token hovers
- `apps/web/src/components/player-management.tsx:58-98` — Non-token hover patterns
- `apps/web/src/components/stat-block-panel.tsx:55-109` — Consistent token usage
- `apps/web/src/lib/utils.ts:1-5``cn()` utility (clsx + twMerge)
## Architecture Documentation
The styling architecture follows this pattern:
1. **Theme layer**: `index.css` defines all color tokens via `@theme`, including semantic hover tokens
2. **Component layer**: `Button` (CVA) provides the shared button abstraction with three variants
3. **Composite layer**: `ConfirmButton` and `OverflowMenu` wrap `Button` with additional behavior
4. **Usage layer**: Components use either `Button` component or raw `<button>` elements with inline Tailwind classes
The `cn()` utility from `lib/utils.ts` is used in 9+ components for conditional class merging.
Custom animations are defined in `index.css` via `@keyframes` + `@utility` pairs: slide-in-right, confirm-pulse, settle-to-bottom, rise-to-center, slide-down-in, slide-up-out, concentration-pulse.
## Open Questions
1. The `hover-action` and `hover-action-bg` tokens are defined but rarely used — `hover-action` appears only once in `action-bar.tsx:565`. Is this intentional or an incomplete migration?
2. The `accent` color (`#3b82f6`) is identical to `primary` — are they intended to diverge in the future, or is this redundancy?
3. Should the hardcoded HP adjust colors (red/emerald) be promoted to theme tokens (e.g., `hover-damage`, `hover-heal`)?

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,10 @@ export type {
} from "./ports.js";
export { removeCombatantUseCase } from "./remove-combatant-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 { setAcUseCase } from "./set-ac-use-case.js";
export { setHpUseCase } from "./set-hp-use-case.js";

View File

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

View File

@@ -106,6 +106,22 @@ describe("editPlayerCharacter", () => {
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", () => {
const result = editPlayerCharacter([makePC()], id, { name: "Strider" });
if (isDomainError(result)) throw new Error(result.message);

View File

@@ -20,8 +20,8 @@ export function createPlayerCharacter(
name: string,
ac: number,
maxHp: number,
color: string,
icon: string,
color: string | undefined,
icon: string | undefined,
): CreatePlayerCharacterSuccess | DomainError {
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 {
kind: "domain-error",
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 {
kind: "domain-error",
code: "invalid-icon",

View File

@@ -18,8 +18,8 @@ interface EditFields {
readonly name?: string;
readonly ac?: number;
readonly maxHp?: number;
readonly color?: string;
readonly icon?: string;
readonly color?: string | null;
readonly icon?: string | 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",
};
}
if (fields.color !== undefined && !VALID_PLAYER_COLORS.has(fields.color)) {
if (
fields.color !== undefined &&
fields.color !== null &&
!VALID_PLAYER_COLORS.has(fields.color)
) {
return {
kind: "domain-error",
code: "invalid-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 {
kind: "domain-error",
code: "invalid-icon",
@@ -78,11 +86,11 @@ function applyFields(
maxHp: fields.maxHp !== undefined ? fields.maxHp : existing.maxHp,
color:
fields.color !== undefined
? (fields.color as PlayerCharacter["color"])
? ((fields.color as PlayerCharacter["color"]) ?? undefined)
: existing.color,
icon:
fields.icon !== undefined
? (fields.icon as PlayerCharacter["icon"])
? ((fields.icon as PlayerCharacter["icon"]) ?? undefined)
: existing.icon,
};
}

View File

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

11
pnpm-lock.yaml generated
View File

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