Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
473f1eaefe | ||
|
|
971e0ded49 | ||
|
|
36dcfc5076 | ||
|
|
127ed01064 | ||
|
|
179c3658ad | ||
|
|
01f2bb3ff1 | ||
|
|
930301de71 | ||
|
|
aa806d4fb9 | ||
|
|
61bc274715 | ||
|
|
1932e837fb | ||
|
|
cce87318fb | ||
|
|
3ef2370a34 | ||
|
|
c75d148d1e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,3 +11,4 @@ Thumbs.db
|
||||
.idea/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
docs/agents/plans/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -16,9 +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 {
|
||||
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";
|
||||
@@ -26,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;
|
||||
@@ -72,6 +76,9 @@ function useActionBarAnimation(combatantCount: number) {
|
||||
export function App() {
|
||||
const {
|
||||
encounter,
|
||||
isEmpty,
|
||||
hasCreatureCombatants,
|
||||
canRollAllInitiative,
|
||||
advanceTurn,
|
||||
retreatTurn,
|
||||
addCombatant,
|
||||
@@ -96,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,
|
||||
@@ -113,34 +114,16 @@ export function App() {
|
||||
} = useBestiary();
|
||||
|
||||
const bulkImport = useBulkImport();
|
||||
const sidePanel = useSidePanelState();
|
||||
|
||||
const [rollSkippedCount, setRollSkippedCount] = useState(0);
|
||||
|
||||
const [selectedCreatureId, setSelectedCreatureId] =
|
||||
useState<CreatureId | null>(null);
|
||||
const [bulkImportMode, setBulkImportMode] = useState(false);
|
||||
const [sourceManagerMode, setSourceManagerMode] = useState(false);
|
||||
const [isRightPanelFolded, setIsRightPanelFolded] = useState(false);
|
||||
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
|
||||
null,
|
||||
);
|
||||
const [isWideDesktop, setIsWideDesktop] = useState(
|
||||
() => window.matchMedia("(min-width: 1280px)").matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const 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(
|
||||
@@ -150,12 +133,12 @@ export function App() {
|
||||
[addFromBestiary],
|
||||
);
|
||||
|
||||
const handleCombatantStatBlock = useCallback((creatureId: string) => {
|
||||
setSelectedCreatureId(creatureId as CreatureId);
|
||||
setBulkImportMode(false);
|
||||
setSourceManagerMode(false);
|
||||
setIsRightPanelFolded(false);
|
||||
}, []);
|
||||
const handleCombatantStatBlock = useCallback(
|
||||
(creatureId: string) => {
|
||||
sidePanel.showCreature(creatureId as CreatureId);
|
||||
},
|
||||
[sidePanel.showCreature],
|
||||
);
|
||||
|
||||
const handleRollInitiative = useCallback(
|
||||
(id: CombatantId) => {
|
||||
@@ -171,31 +154,17 @@ export function App() {
|
||||
}
|
||||
}, [makeStore, getCreature]);
|
||||
|
||||
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);
|
||||
setBulkImportMode(false);
|
||||
setSourceManagerMode(false);
|
||||
setIsRightPanelFolded(false);
|
||||
}, []);
|
||||
|
||||
const handleBulkImport = useCallback(() => {
|
||||
setBulkImportMode(true);
|
||||
setSourceManagerMode(false);
|
||||
setSelectedCreatureId(null);
|
||||
setIsRightPanelFolded(false);
|
||||
}, []);
|
||||
|
||||
const handleOpenSourceManager = useCallback(() => {
|
||||
setSourceManagerMode(true);
|
||||
setBulkImportMode(false);
|
||||
setSelectedCreatureId(null);
|
||||
setIsRightPanelFolded(false);
|
||||
}, []);
|
||||
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;
|
||||
sidePanel.showCreature(cId);
|
||||
},
|
||||
[sidePanel.showCreature],
|
||||
);
|
||||
|
||||
const handleStartBulkImport = useCallback(
|
||||
(baseUrl: string) => {
|
||||
@@ -210,33 +179,12 @@ export function App() {
|
||||
);
|
||||
|
||||
const handleBulkImportDone = useCallback(() => {
|
||||
setBulkImportMode(false);
|
||||
sidePanel.dismissPanel();
|
||||
bulkImport.reset();
|
||||
}, [bulkImport.reset]);
|
||||
|
||||
const handleDismissBrowsePanel = useCallback(() => {
|
||||
setSelectedCreatureId(null);
|
||||
setBulkImportMode(false);
|
||||
setSourceManagerMode(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
|
||||
@@ -258,18 +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);
|
||||
setBulkImportMode(false);
|
||||
setSourceManagerMode(false);
|
||||
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
|
||||
|
||||
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,
|
||||
);
|
||||
sidePanel.showCreature(active.creatureId as CreatureId);
|
||||
}, [
|
||||
encounter.activeIndex,
|
||||
encounter.combatants,
|
||||
isLoaded,
|
||||
sidePanel.showCreature,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
@@ -301,16 +244,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={handleOpenSourceManager}
|
||||
onOpenSourceManager={sidePanel.showSourceManager}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -358,16 +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={handleOpenSourceManager}
|
||||
onOpenSourceManager={sidePanel.showSourceManager}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -375,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={() => {}}
|
||||
@@ -396,53 +343,32 @@ 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={sourceManagerMode}
|
||||
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={() => {}}
|
||||
/>
|
||||
)}
|
||||
{bulkImport.state.status === "complete" && !bulkImportMode && (
|
||||
<Toast
|
||||
message="All sources loaded"
|
||||
onDismiss={bulkImport.reset}
|
||||
autoDismissMs={3000}
|
||||
/>
|
||||
)}
|
||||
{bulkImport.state.status === "partial-failure" && !bulkImportMode && (
|
||||
<Toast
|
||||
message={`Loaded ${bulkImport.state.completed}/${bulkImport.state.total} sources (${bulkImport.state.failed} failed)`}
|
||||
onDismiss={bulkImport.reset}
|
||||
/>
|
||||
)}
|
||||
<BulkImportToasts
|
||||
state={bulkImport.state}
|
||||
visible={!sidePanel.bulkImportMode || sidePanel.isRightPanelCollapsed}
|
||||
onReset={bulkImport.reset}
|
||||
/>
|
||||
|
||||
{rollSkippedCount > 0 && (
|
||||
<Toast
|
||||
@@ -452,43 +378,12 @@ export function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreatePlayerModal
|
||||
open={createPlayerOpen}
|
||||
onClose={() => {
|
||||
setCreatePlayerOpen(false);
|
||||
setEditingPlayer(undefined);
|
||||
}}
|
||||
onSave={(name, ac, maxHp, color, icon) => {
|
||||
if (editingPlayer) {
|
||||
editPlayerCharacter?.(editingPlayer.id, {
|
||||
name,
|
||||
ac,
|
||||
maxHp,
|
||||
color: color ?? null,
|
||||
icon: icon ?? null,
|
||||
});
|
||||
} 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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
Plus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { type FormEvent, type RefObject, useState } from "react";
|
||||
import {
|
||||
type FormEvent,
|
||||
type RefObject,
|
||||
useDeferredValue,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { SearchResult } from "../hooks/use-bestiary.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { D20Icon } from "./d20-icon.js";
|
||||
@@ -270,6 +275,8 @@ export function ActionBar({
|
||||
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("");
|
||||
@@ -394,7 +401,8 @@ export function ActionBar({
|
||||
}
|
||||
};
|
||||
|
||||
const hasSuggestions = suggestions.length > 0 || pcMatches.length > 0;
|
||||
const hasSuggestions =
|
||||
deferredSuggestions.length > 0 || deferredPcMatches.length > 0;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!hasSuggestions) return;
|
||||
@@ -495,10 +503,10 @@ export function ActionBar({
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{browseMode && suggestions.length > 0 && (
|
||||
{browseMode && deferredSuggestions.length > 0 && (
|
||||
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
|
||||
<ul className="max-h-48 overflow-y-auto py-1">
|
||||
{suggestions.map((result, i) => (
|
||||
{deferredSuggestions.map((result, i) => (
|
||||
<li key={creatureKey(result)}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -524,8 +532,8 @@ export function ActionBar({
|
||||
{!browseMode && hasSuggestions && (
|
||||
<AddModeSuggestions
|
||||
nameInput={nameInput}
|
||||
suggestions={suggestions}
|
||||
pcMatches={pcMatches}
|
||||
suggestions={deferredSuggestions}
|
||||
pcMatches={deferredPcMatches}
|
||||
suggestionIndex={suggestionIndex}
|
||||
queued={queued}
|
||||
onDismiss={dismissSuggestions}
|
||||
|
||||
49
apps/web/src/components/bulk-import-toasts.tsx
Normal file
49
apps/web/src/components/bulk-import-toasts.tsx
Normal 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;
|
||||
}
|
||||
91
apps/web/src/components/player-character-section.tsx
Normal file
91
apps/web/src/components/player-character-section.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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" />
|
||||
@@ -57,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"
|
||||
|
||||
@@ -22,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;
|
||||
@@ -42,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}
|
||||
@@ -70,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;
|
||||
}) {
|
||||
@@ -87,9 +87,9 @@ function PanelHeader({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onToggleFold}
|
||||
onClick={onToggleCollapse}
|
||||
className="text-muted-foreground"
|
||||
aria-label="Fold stat block panel"
|
||||
aria-label="Collapse stat block panel"
|
||||
>
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -124,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}
|
||||
/>
|
||||
@@ -206,7 +206,7 @@ function MobileDrawer({
|
||||
size="icon-sm"
|
||||
onClick={onDismiss}
|
||||
className="text-muted-foreground"
|
||||
aria-label="Fold stat block panel"
|
||||
aria-label="Collapse stat block panel"
|
||||
>
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -227,8 +227,8 @@ export function StatBlockPanel({
|
||||
uploadAndCacheSource,
|
||||
refreshCache,
|
||||
panelRole,
|
||||
isFolded,
|
||||
onToggleFold,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onPin,
|
||||
onUnpin,
|
||||
showPinButton,
|
||||
@@ -341,12 +341,12 @@ export function StatBlockPanel({
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<DesktopPanel
|
||||
isFolded={isFolded}
|
||||
isCollapsed={isCollapsed}
|
||||
side={side}
|
||||
creatureName={creatureName}
|
||||
panelRole={panelRole}
|
||||
showPinButton={showPinButton}
|
||||
onToggleFold={onToggleFold}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onPin={onPin}
|
||||
onUnpin={onUnpin}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
for (const c of creatures) {
|
||||
creatureMapRef.current.set(c.id, c);
|
||||
}
|
||||
setTick((t) => t + 1);
|
||||
setCreatureMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const c of creatures) {
|
||||
next.set(c.id, c);
|
||||
}
|
||||
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);
|
||||
for (const c of creatures) {
|
||||
creatureMapRef.current.set(c.id, c);
|
||||
}
|
||||
setTick((t) => t + 1);
|
||||
setCreatureMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const c of creatures) {
|
||||
next.set(c.id, c);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const refreshCache = useCallback(async (): Promise<void> => {
|
||||
const map = await bestiaryCache.loadAllCachedCreatures();
|
||||
creatureMapRef.current = map;
|
||||
setTick((t) => t + 1);
|
||||
setCreatureMap(map);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
|
||||
@@ -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,
|
||||
|
||||
101
apps/web/src/hooks/use-side-panel-state.ts
Normal file
101
apps/web/src/hooks/use-side-panel-state.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -1,536 +0,0 @@
|
||||
---
|
||||
date: "2026-03-13T14:58:42.882813+00:00"
|
||||
git_commit: 75778884bd1be7d135b2f5ea9b8a8e77a0149f7b
|
||||
branch: main
|
||||
topic: "Declutter Action Bars"
|
||||
tags: [plan, turn-navigation, action-bar, overflow-menu, ux]
|
||||
status: draft
|
||||
---
|
||||
|
||||
# Declutter Action Bars — Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Reorganize buttons across the top bar (TurnNavigation) and bottom bar (ActionBar) to reduce visual clutter and improve UX. Each bar gets a clear purpose: the top bar is for turn navigation + encounter lifecycle, the bottom bar is for adding combatants + setup actions.
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
**Top bar** (`turn-navigation.tsx`) has 5 buttons + center info:
|
||||
```
|
||||
[ Prev ] | [ R1 Dwarf ] | [ D20 Library Trash ] [ Next ]
|
||||
```
|
||||
The D20 (roll all initiative) and Library (manage sources) buttons are unrelated to turn navigation — they're setup/utility actions that add noise.
|
||||
|
||||
**Bottom bar** (`action-bar.tsx`) has an input, Add button, and 3 icon buttons:
|
||||
```
|
||||
[ + Add combatants... ] [ Add ] [ Users Eye Import ]
|
||||
```
|
||||
The icon cluster (Users, Eye, Import) is cryptic — three ghost icon buttons with no labels, requiring hover to discover purpose. The Eye button opens a separate search dropdown for browsing stat blocks, which duplicates the existing search input.
|
||||
|
||||
### Key Discoveries:
|
||||
- `rollAllInitiativeUseCase` (`packages/application/src/roll-all-initiative-use-case.ts`) applies to combatants with `creatureId` AND no `initiative` set — this defines the conditional visibility logic
|
||||
- `Combatant.initiative` is `number | undefined` and `Combatant.creatureId` is `CreatureId | undefined` (`packages/domain/src/types.ts`)
|
||||
- No existing dropdown/menu UI component — the overflow menu needs a new component
|
||||
- Lucide provides `EllipsisVertical` for the kebab menu trigger
|
||||
- The stat block viewer already has its own search input, results list, and keyboard navigation (`action-bar.tsx:65-236`) — in browse mode, we reuse the main input for this instead
|
||||
|
||||
## Desired End State
|
||||
|
||||
### UI Mockups
|
||||
|
||||
**Top bar (after):**
|
||||
```
|
||||
[ Prev ] [ R1 Dwarf ] [ Trash ] [ Next ]
|
||||
```
|
||||
4 elements. Clean, focused on turn flow + encounter lifecycle.
|
||||
|
||||
**Bottom bar — add mode (default):**
|
||||
```
|
||||
[ + Add combatants... 👁 ] [ Add ] [ D20? ] [ ⋮ ]
|
||||
```
|
||||
The Eye icon sits inside/beside the input as a toggle. D20 appears conditionally. Kebab menu holds infrequent actions.
|
||||
|
||||
**Bottom bar — browse mode (Eye toggled on):**
|
||||
```
|
||||
[ 🔍 Search stat blocks... 👁 ] [ ⋮ ]
|
||||
```
|
||||
The input switches purpose: placeholder changes, typing searches stat blocks instead of adding combatants. The Add button and D20 hide (irrelevant in browse mode). Eye icon stays as the toggle to switch back. Selecting a result opens the stat block panel and exits browse mode.
|
||||
|
||||
**Overflow menu (⋮ clicked):**
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ 👥 Player Characters │
|
||||
│ 📚 Manage Sources │
|
||||
│ 📥 Bulk Import │
|
||||
└──────────────────────┘
|
||||
```
|
||||
Labeled items with icons — discoverable without hover.
|
||||
|
||||
### Key Discoveries:
|
||||
- `sourceManagerOpen` state lives in App.tsx:116 — the overflow menu's "Manage Sources" item needs the same toggle callback
|
||||
- The stat block viewer state (viewerOpen, viewerQuery, viewerResults, viewerIndex) in action-bar.tsx:66-71 gets replaced by a `browseMode` boolean that repurposes the main input
|
||||
- The viewer's separate input, dropdown, and keyboard handling (action-bar.tsx:188-248) can be removed — browse mode reuses the existing input and suggestion dropdown infrastructure
|
||||
|
||||
## What We're NOT Doing
|
||||
|
||||
- Changing domain logic or use cases
|
||||
- Modifying ConfirmButton behavior
|
||||
- Changing the stat block panel itself
|
||||
- Altering animation logic (useActionBarAnimation)
|
||||
- Modifying combatant row buttons
|
||||
- Changing how SourceManager works (just moving where the trigger lives)
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
Four phases, each independently testable. Phase 1 simplifies the top bar (pure removal). Phase 2 adds the overflow menu component. Phase 3 reworks the ActionBar (browse toggle + conditional D20 + overflow integration). Phase 4 wires everything together in App.tsx.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Simplify TurnNavigation
|
||||
|
||||
### Overview
|
||||
Strip TurnNavigation down to just turn controls + clear encounter. Remove Roll All Initiative and Manage Sources buttons and their associated props.
|
||||
|
||||
### Changes Required:
|
||||
|
||||
#### [x] 1. Update TurnNavigation component
|
||||
**File**: `apps/web/src/components/turn-navigation.tsx`
|
||||
**Changes**:
|
||||
- Remove `onRollAllInitiative` and `onOpenSourceManager` from props interface
|
||||
- Remove the D20 button (lines 53-62)
|
||||
- Remove the Library button (lines 63-72)
|
||||
- Remove the inner `gap-0` div wrapper (lines 52, 80) since only the ConfirmButton remains
|
||||
- Remove unused imports: `Library` from lucide-react, `D20Icon`
|
||||
- Adjust layout: ConfirmButton + Next button grouped with `gap-3`
|
||||
|
||||
Result:
|
||||
```tsx
|
||||
interface TurnNavigationProps {
|
||||
encounter: Encounter;
|
||||
onAdvanceTurn: () => void;
|
||||
onRetreatTurn: () => void;
|
||||
onClearEncounter: () => void;
|
||||
}
|
||||
|
||||
// Layout becomes:
|
||||
// [ Prev ] | [ R1 Name ] | [ Trash ] [ Next ]
|
||||
```
|
||||
|
||||
#### [x] 2. Update TurnNavigation usage in App.tsx
|
||||
**File**: `apps/web/src/App.tsx`
|
||||
**Changes**:
|
||||
- Remove `onRollAllInitiative` and `onOpenSourceManager` props from the `<TurnNavigation>` call (lines 256-257)
|
||||
|
||||
### Success Criteria:
|
||||
|
||||
#### Automated Verification:
|
||||
- [x] `pnpm check` passes (typecheck catches removed props, lint catches unused imports)
|
||||
|
||||
#### Manual Verification:
|
||||
- [ ] Top bar shows only: Prev, round badge + name, trash, Next
|
||||
- [ ] Prev/Next/Clear buttons still work as before
|
||||
- [ ] Top bar animation (slide in/out) unchanged
|
||||
|
||||
**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Create Overflow Menu Component
|
||||
|
||||
### Overview
|
||||
Build a reusable overflow menu (kebab menu) component with click-outside and Escape handling, following the same patterns as ConfirmButton and the existing viewer dropdown.
|
||||
|
||||
### Changes Required:
|
||||
|
||||
#### [x] 1. Create OverflowMenu component
|
||||
**File**: `apps/web/src/components/ui/overflow-menu.tsx` (new file)
|
||||
**Changes**: Create a dropdown menu triggered by an EllipsisVertical icon button. Features:
|
||||
- Toggle open/close on button click
|
||||
- Close on click outside (document mousedown listener, same pattern as confirm-button.tsx:44-67)
|
||||
- Close on Escape key
|
||||
- Renders above the trigger (bottom-full positioning, same as action-bar suggestion dropdown)
|
||||
- Each item: icon + label, full-width clickable row
|
||||
- Clicking an item calls its action and closes the menu
|
||||
|
||||
```tsx
|
||||
import { EllipsisVertical } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "./button";
|
||||
|
||||
export interface OverflowMenuItem {
|
||||
readonly icon: ReactNode;
|
||||
readonly label: string;
|
||||
readonly onClick: () => void;
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
|
||||
interface OverflowMenuProps {
|
||||
readonly items: readonly OverflowMenuItem[];
|
||||
}
|
||||
|
||||
export function OverflowMenu({ items }: OverflowMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-label="More actions"
|
||||
title="More actions"
|
||||
>
|
||||
<EllipsisVertical className="h-5 w-5" />
|
||||
</Button>
|
||||
{open && (
|
||||
<div className="absolute bottom-full right-0 z-50 mb-1 min-w-48 rounded-md border border-border bg-card py-1 shadow-lg">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-hover-neutral-bg disabled:pointer-events-none disabled:opacity-50"
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
item.onClick();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Success Criteria:
|
||||
|
||||
#### Automated Verification:
|
||||
- [x] `pnpm check` passes (new file compiles, no unused exports yet — will be used in phase 3)
|
||||
|
||||
#### Manual Verification:
|
||||
- [ ] N/A — component not yet wired into the UI
|
||||
|
||||
**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Rework ActionBar
|
||||
|
||||
### Overview
|
||||
Replace the icon button cluster with: (1) an Eye toggle on the input that switches between add mode and browse mode, (2) a conditional Roll All Initiative button, and (3) the overflow menu for infrequent actions.
|
||||
|
||||
### Changes Required:
|
||||
|
||||
#### [x] 1. Update ActionBarProps
|
||||
**File**: `apps/web/src/components/action-bar.tsx`
|
||||
**Changes**: Add new props, keep existing ones needed for overflow menu items:
|
||||
```tsx
|
||||
interface ActionBarProps {
|
||||
// ... existing props stay ...
|
||||
onRollAllInitiative?: () => void; // new — moved from top bar
|
||||
showRollAllInitiative?: boolean; // new — conditional visibility
|
||||
onOpenSourceManager?: () => void; // new — moved from top bar
|
||||
}
|
||||
```
|
||||
|
||||
#### [x] 2. Add browse mode state
|
||||
**File**: `apps/web/src/components/action-bar.tsx`
|
||||
**Changes**: Replace the separate viewer state (viewerOpen, viewerQuery, viewerResults, viewerIndex, viewerRef, viewerInputRef — lines 66-71) with a single `browseMode` boolean:
|
||||
|
||||
```tsx
|
||||
const [browseMode, setBrowseMode] = useState(false);
|
||||
```
|
||||
|
||||
Remove all viewer-specific state variables and handlers:
|
||||
- `viewerOpen`, `viewerQuery`, `viewerResults`, `viewerIndex` (lines 66-69)
|
||||
- `viewerRef`, `viewerInputRef` (lines 70-71)
|
||||
- `openViewer`, `closeViewer` (lines 189-202)
|
||||
- `handleViewerQueryChange`, `handleViewerSelect`, `handleViewerKeyDown` (lines 204-236)
|
||||
- The viewer click-outside effect (lines 239-248)
|
||||
|
||||
#### [x] 3. Rework the input area with Eye toggle
|
||||
**File**: `apps/web/src/components/action-bar.tsx`
|
||||
**Changes**: Add an Eye icon button inside the input wrapper that toggles browse mode. When browse mode is active:
|
||||
- Placeholder changes to "Search stat blocks..."
|
||||
- Typing calls `bestiarySearch` but selecting a result calls `onViewStatBlock` instead of queuing/adding
|
||||
- The suggestion dropdown shows results but clicking opens stat block panel instead of adding
|
||||
- Add button and custom fields (Init/AC/MaxHP) are hidden
|
||||
- D20 button is hidden
|
||||
|
||||
When toggling browse mode off, clear the input and suggestions.
|
||||
|
||||
The Eye icon sits to the right of the input inside the `relative flex-1` wrapper:
|
||||
```tsx
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={nameInput}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
onKeyDown={browseMode ? handleBrowseKeyDown : handleKeyDown}
|
||||
placeholder={browseMode ? "Search stat blocks..." : "+ Add combatants"}
|
||||
className="max-w-xs pr-8"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
{bestiaryLoaded && onViewStatBlock && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
|
||||
browseMode && "text-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
setBrowseMode((m) => !m);
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
setQueued(null);
|
||||
setSuggestionIndex(-1);
|
||||
}}
|
||||
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
|
||||
aria-label={browseMode ? "Switch to add mode" : "Browse stat blocks"}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{/* suggestion dropdown — behavior changes based on browseMode */}
|
||||
</div>
|
||||
```
|
||||
|
||||
Import `cn` from `../../lib/utils` (already used by other components).
|
||||
|
||||
#### [x] 4. Update suggestion dropdown for browse mode
|
||||
**File**: `apps/web/src/components/action-bar.tsx`
|
||||
**Changes**: In browse mode, the suggestion dropdown behaves differently:
|
||||
- No "Add as custom" row at the top
|
||||
- No player character matches section
|
||||
- No queuing (plus/minus/confirm) — clicking a result calls `onViewStatBlock` and exits browse mode
|
||||
- Keyboard Enter on a highlighted result calls `onViewStatBlock` and exits browse mode
|
||||
|
||||
Add a `handleBrowseKeyDown` handler:
|
||||
```tsx
|
||||
const handleBrowseKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setBrowseMode(false);
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setSuggestionIndex(-1);
|
||||
return;
|
||||
}
|
||||
if (suggestions.length === 0) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
||||
} else if (e.key === "Enter" && suggestionIndex >= 0) {
|
||||
e.preventDefault();
|
||||
onViewStatBlock?.(suggestions[suggestionIndex]);
|
||||
setBrowseMode(false);
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setSuggestionIndex(-1);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
In the suggestion dropdown JSX, conditionally render based on `browseMode`:
|
||||
- Browse mode: simple list of creature results, click → `onViewStatBlock` + exit browse mode
|
||||
- Add mode: existing behavior (custom row, PC matches, queuing)
|
||||
|
||||
#### [x] 5. Replace icon button cluster with D20 + overflow menu
|
||||
**File**: `apps/web/src/components/action-bar.tsx`
|
||||
**Changes**: Replace the `div.flex.items-center.gap-0` block (lines 443-529) containing Users, Eye, and Import buttons with:
|
||||
|
||||
```tsx
|
||||
{!browseMode && (
|
||||
<>
|
||||
<Button type="submit" size="sm">
|
||||
Add
|
||||
</Button>
|
||||
{showRollAllInitiative && onRollAllInitiative && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-hover-action"
|
||||
onClick={onRollAllInitiative}
|
||||
title="Roll all initiative"
|
||||
aria-label="Roll all initiative"
|
||||
>
|
||||
<D20Icon className="h-6 w-6" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<OverflowMenu items={overflowItems} />
|
||||
```
|
||||
|
||||
Build the `overflowItems` array from props:
|
||||
```tsx
|
||||
const overflowItems: OverflowMenuItem[] = [];
|
||||
if (onManagePlayers) {
|
||||
overflowItems.push({
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
label: "Player Characters",
|
||||
onClick: onManagePlayers,
|
||||
});
|
||||
}
|
||||
if (onOpenSourceManager) {
|
||||
overflowItems.push({
|
||||
icon: <Library className="h-4 w-4" />,
|
||||
label: "Manage Sources",
|
||||
onClick: onOpenSourceManager,
|
||||
});
|
||||
}
|
||||
if (bestiaryLoaded && onBulkImport) {
|
||||
overflowItems.push({
|
||||
icon: <Import className="h-4 w-4" />,
|
||||
label: "Bulk Import",
|
||||
onClick: onBulkImport,
|
||||
disabled: bulkImportDisabled,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### [x] 6. Clean up imports
|
||||
**File**: `apps/web/src/components/action-bar.tsx`
|
||||
**Changes**:
|
||||
- Add imports: `D20Icon`, `OverflowMenu` + `OverflowMenuItem`, `Library` from lucide-react, `cn` from utils
|
||||
- Remove imports that are no longer needed after removing the standalone viewer: check which of `Eye`, `Import`, `Users` are still used (Eye stays for the toggle, Users and Import stay for overflow item icons, Library is new)
|
||||
- The `Check`, `Minus`, `Plus` imports stay (used in queuing UI)
|
||||
|
||||
### Success Criteria:
|
||||
|
||||
#### Automated Verification:
|
||||
- [x] `pnpm check` passes
|
||||
|
||||
#### Manual Verification:
|
||||
- [ ] Bottom bar shows: input with Eye toggle, Add button, (conditional D20), kebab menu
|
||||
- [ ] Eye toggle switches input between "add" and "browse" modes
|
||||
- [ ] In browse mode: typing shows bestiary results, clicking one opens stat block panel, exits browse mode
|
||||
- [ ] In browse mode: Add button and D20 are hidden, overflow menu stays visible
|
||||
- [ ] In add mode: existing behavior works (search, queue, custom fields, PC matches)
|
||||
- [ ] Overflow menu opens/closes on click, closes on Escape and click-outside
|
||||
- [ ] Overflow menu items (Player Characters, Manage Sources, Bulk Import) trigger correct actions
|
||||
- [ ] D20 button appears only when bestiary combatants lack initiative, disappears when all have values
|
||||
|
||||
**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Wire Up App.tsx
|
||||
|
||||
### Overview
|
||||
Pass the new props to ActionBar — roll all initiative handler, conditional visibility flag, and source manager toggle. Remove the now-unused `onOpenSourceManager` callback from the TurnNavigation call (already removed in Phase 1) and ensure sourceManagerOpen toggle is routed through the overflow menu.
|
||||
|
||||
### Changes Required:
|
||||
|
||||
#### [x] 1. Compute showRollAllInitiative flag
|
||||
**File**: `apps/web/src/App.tsx`
|
||||
**Changes**: Add a derived boolean that checks if any combatant with a `creatureId` lacks an `initiative` value:
|
||||
|
||||
```tsx
|
||||
const showRollAllInitiative = encounter.combatants.some(
|
||||
(c) => c.creatureId != null && c.initiative == null,
|
||||
);
|
||||
```
|
||||
|
||||
Place this near `const isEmpty = ...` (line 241).
|
||||
|
||||
#### [x] 2. Pass new props to both ActionBar instances
|
||||
**File**: `apps/web/src/App.tsx`
|
||||
**Changes**: Add to both `<ActionBar>` calls (empty state at ~line 269 and populated state at ~line 328):
|
||||
|
||||
```tsx
|
||||
<ActionBar
|
||||
// ... existing props ...
|
||||
onRollAllInitiative={handleRollAllInitiative}
|
||||
showRollAllInitiative={showRollAllInitiative}
|
||||
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
|
||||
/>
|
||||
```
|
||||
|
||||
#### [x] 3. Remove stale code
|
||||
**File**: `apps/web/src/App.tsx`
|
||||
**Changes**:
|
||||
- The `onRollAllInitiative` and `onOpenSourceManager` props were already removed from `<TurnNavigation>` in Phase 1 — verify no references remain
|
||||
- Verify `sourceManagerOpen` state and the `<SourceManager>` rendering block (lines 287-291) still work correctly — the SourceManager inline panel is still toggled by the same state, just from a different trigger location
|
||||
|
||||
### Success Criteria:
|
||||
|
||||
#### Automated Verification:
|
||||
- [x] `pnpm check` passes
|
||||
|
||||
#### Manual Verification:
|
||||
- [ ] Top bar: only Prev, round badge + name, trash, Next — no D20 or Library buttons
|
||||
- [ ] Bottom bar: input with Eye toggle, Add, conditional D20, overflow menu
|
||||
- [ ] Roll All Initiative (D20 in bottom bar): visible when bestiary creatures lack initiative, hidden after rolling
|
||||
- [ ] Overflow → Player Characters: opens player management modal
|
||||
- [ ] Overflow → Manage Sources: toggles source manager panel (same as before, just different trigger)
|
||||
- [ ] Overflow → Bulk Import: opens bulk import mode
|
||||
- [ ] Browse mode (Eye toggle): search stat blocks without adding, selecting opens panel
|
||||
- [ ] Clear encounter (top bar trash): still works with two-click confirmation
|
||||
- [ ] All animations (bar transitions) unchanged
|
||||
- [ ] Empty state: ActionBar centered with all functionality accessible
|
||||
|
||||
**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase.
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests:
|
||||
- No domain/application changes — existing tests should pass unchanged
|
||||
- `pnpm check` covers typecheck + lint + existing test suite
|
||||
|
||||
### Manual Testing Steps:
|
||||
1. Start with empty encounter — verify ActionBar is centered with Eye toggle and overflow menu
|
||||
2. Add a bestiary creature — verify D20 appears in bottom bar, top bar slides in with just 4 elements
|
||||
3. Click D20 → initiative rolls → D20 disappears from bottom bar
|
||||
4. Toggle Eye → input switches to browse mode → search and select → stat block opens → exits browse mode
|
||||
5. Open overflow menu → click each item → verify correct modal/panel opens
|
||||
6. Click trash in top bar → confirm → encounter clears, back to empty state
|
||||
7. Add custom creature (no creatureId) → D20 should not appear (no bestiary creatures)
|
||||
8. Add mix of custom + bestiary creatures → D20 visible → roll all → D20 hidden
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
None — this is a pure UI reorganization with no new data fetching, state management changes, or rendering overhead. The `showRollAllInitiative` computation is a simple `.some()` over the combatant array, which is negligible.
|
||||
|
||||
## References
|
||||
|
||||
- Research: `docs/agents/research/2026-03-13-action-bars-and-buttons.md`
|
||||
- Top bar: `apps/web/src/components/turn-navigation.tsx`
|
||||
- Bottom bar: `apps/web/src/components/action-bar.tsx`
|
||||
- App layout: `apps/web/src/App.tsx`
|
||||
- Button: `apps/web/src/components/ui/button.tsx`
|
||||
- ConfirmButton: `apps/web/src/components/ui/confirm-button.tsx`
|
||||
- Roll all use case: `packages/application/src/roll-all-initiative-use-case.ts`
|
||||
- Combatant type: `packages/domain/src/types.ts`
|
||||
@@ -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",
|
||||
|
||||
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user