Compare commits
23 Commits
0.6.0
...
473f1eaefe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
473f1eaefe | ||
|
|
971e0ded49 | ||
|
|
36dcfc5076 | ||
|
|
127ed01064 | ||
|
|
179c3658ad | ||
|
|
01f2bb3ff1 | ||
|
|
930301de71 | ||
|
|
aa806d4fb9 | ||
|
|
61bc274715 | ||
|
|
1932e837fb | ||
|
|
cce87318fb | ||
|
|
3ef2370a34 | ||
|
|
c75d148d1e | ||
|
|
63e233bd8d | ||
|
|
8c62ec28f2 | ||
|
|
72195e90f6 | ||
|
|
6ac8e67970 | ||
|
|
a4797d5b15 | ||
|
|
d48e39ced4 | ||
|
|
b7406c4b54 | ||
|
|
07cdd4867a | ||
|
|
85acb5c185 | ||
|
|
f9ef64bb00 |
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.
|
||||
|
||||
@@ -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 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);
|
||||
}, []);
|
||||
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) => {
|
||||
@@ -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,13 +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;
|
||||
const showRollAllInitiative = 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">
|
||||
@@ -273,27 +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={showRollAllInitiative}
|
||||
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
|
||||
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">
|
||||
@@ -335,15 +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={showRollAllInitiative}
|
||||
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
|
||||
showRollAllInitiative={hasCreatureCombatants}
|
||||
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||
onOpenSourceManager={sidePanel.showSourceManager}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -351,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={() => {}}
|
||||
@@ -372,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={() => {}}
|
||||
/>
|
||||
)}
|
||||
{bulkImport.state.status === "complete" && !bulkImportMode && (
|
||||
<Toast
|
||||
message="All sources loaded"
|
||||
onDismiss={bulkImport.reset}
|
||||
autoDismissMs={3000}
|
||||
/>
|
||||
)}
|
||||
{bulkImport.state.status === "partial-failure" && !bulkImportMode && (
|
||||
<Toast
|
||||
message={`Loaded ${bulkImport.state.completed}/${bulkImport.state.total} sources (${bulkImport.state.failed} failed)`}
|
||||
onDismiss={bulkImport.reset}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreatePlayerModal
|
||||
open={createPlayerOpen}
|
||||
onClose={() => {
|
||||
setCreatePlayerOpen(false);
|
||||
setEditingPlayer(undefined);
|
||||
}}
|
||||
onSave={(name, ac, maxHp, color, icon) => {
|
||||
if (editingPlayer) {
|
||||
editPlayerCharacter?.(editingPlayer.id, {
|
||||
name,
|
||||
ac,
|
||||
maxHp,
|
||||
color,
|
||||
icon,
|
||||
});
|
||||
} else {
|
||||
createPlayerCharacter(name, ac, maxHp, color, icon);
|
||||
}
|
||||
}}
|
||||
playerCharacter={editingPlayer}
|
||||
<BulkImportToasts
|
||||
state={bulkImport.state}
|
||||
visible={!sidePanel.bulkImportMode || sidePanel.isRightPanelCollapsed}
|
||||
onReset={bulkImport.reset}
|
||||
/>
|
||||
|
||||
<PlayerManagement
|
||||
open={managementOpen}
|
||||
onClose={() => setManagementOpen(false)}
|
||||
{rollSkippedCount > 0 && (
|
||||
<Toast
|
||||
message={`${rollSkippedCount} skipped — bestiary source not loaded`}
|
||||
onDismiss={() => setRollSkippedCount(0)}
|
||||
autoDismissMs={4000}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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";
|
||||
@@ -40,6 +45,7 @@ interface ActionBarProps {
|
||||
onManagePlayers?: () => void;
|
||||
onRollAllInitiative?: () => void;
|
||||
showRollAllInitiative?: boolean;
|
||||
rollAllInitiativeDisabled?: boolean;
|
||||
onOpenSourceManager?: () => void;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
@@ -60,6 +66,7 @@ function AddModeSuggestions({
|
||||
onSetQueued,
|
||||
onConfirmQueued,
|
||||
onAddFromPlayerCharacter,
|
||||
onClear,
|
||||
}: {
|
||||
nameInput: string;
|
||||
suggestions: SearchResult[];
|
||||
@@ -67,6 +74,7 @@ function AddModeSuggestions({
|
||||
suggestionIndex: number;
|
||||
queued: QueuedCreature | null;
|
||||
onDismiss: () => void;
|
||||
onClear: () => void;
|
||||
onClickSuggestion: (result: SearchResult) => void;
|
||||
onSetSuggestionIndex: (i: number) => void;
|
||||
onSetQueued: (q: QueuedCreature | null) => void;
|
||||
@@ -95,9 +103,12 @@ function AddModeSuggestions({
|
||||
</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
|
||||
@@ -106,7 +117,7 @@ function AddModeSuggestions({
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
onAddFromPlayerCharacter?.(pc);
|
||||
onDismiss();
|
||||
onClear();
|
||||
}}
|
||||
>
|
||||
{PcIcon && (
|
||||
@@ -235,7 +246,7 @@ function buildOverflowItems(opts: {
|
||||
if (opts.bestiaryLoaded && opts.onBulkImport) {
|
||||
items.push({
|
||||
icon: <Import className="h-4 w-4" />,
|
||||
label: "Bulk Import",
|
||||
label: "Import All Sources",
|
||||
onClick: opts.onBulkImport,
|
||||
disabled: opts.bulkImportDisabled,
|
||||
});
|
||||
@@ -257,12 +268,15 @@ export function ActionBar({
|
||||
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("");
|
||||
@@ -284,6 +298,13 @@ export function ActionBar({
|
||||
setSuggestionIndex(-1);
|
||||
};
|
||||
|
||||
const dismissSuggestions = () => {
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
setQueued(null);
|
||||
setSuggestionIndex(-1);
|
||||
};
|
||||
|
||||
const confirmQueued = () => {
|
||||
if (!queued) return;
|
||||
for (let i = 0; i < queued.count; i++) {
|
||||
@@ -380,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;
|
||||
@@ -395,7 +417,7 @@ export function ActionBar({
|
||||
e.preventDefault();
|
||||
handleEnter();
|
||||
} else if (e.key === "Escape") {
|
||||
clearInput();
|
||||
dismissSuggestions();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -481,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"
|
||||
@@ -510,11 +532,12 @@ export function ActionBar({
|
||||
{!browseMode && hasSuggestions && (
|
||||
<AddModeSuggestions
|
||||
nameInput={nameInput}
|
||||
suggestions={suggestions}
|
||||
pcMatches={pcMatches}
|
||||
suggestions={deferredSuggestions}
|
||||
pcMatches={deferredPcMatches}
|
||||
suggestionIndex={suggestionIndex}
|
||||
queued={queued}
|
||||
onDismiss={clearInput}
|
||||
onDismiss={dismissSuggestions}
|
||||
onClear={clearInput}
|
||||
onClickSuggestion={handleClickSuggestion}
|
||||
onSetSuggestionIndex={setSuggestionIndex}
|
||||
onSetQueued={setQueued}
|
||||
@@ -553,9 +576,7 @@ export function ActionBar({
|
||||
</div>
|
||||
)}
|
||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||
<Button type="submit" size="sm">
|
||||
Add
|
||||
</Button>
|
||||
<Button type="submit">Add</Button>
|
||||
)}
|
||||
{showRollAllInitiative && onRollAllInitiative && (
|
||||
<Button
|
||||
@@ -564,6 +585,7 @@ export function ActionBar({
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-hover-action"
|
||||
onClick={onRollAllInitiative}
|
||||
disabled={rollAllInitiativeDisabled}
|
||||
title="Roll all initiative"
|
||||
aria-label="Roll all initiative"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
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,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>
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -23,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}
|
||||
@@ -52,6 +53,7 @@ export function TurnNavigation({
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onAdvanceTurn}
|
||||
disabled={!hasCombatants}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -54,7 +54,7 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
|
||||
<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-muted/20 disabled:pointer-events-none disabled:opacity-50"
|
||||
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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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`
|
||||
188
docs/agents/research/2026-03-13-css-classes-buttons-hover.md
Normal file
188
docs/agents/research/2026-03-13-css-classes-buttons-hover.md
Normal 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`)?
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
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