9 Commits
0.5.0 ... 0.7.1

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:31:25 +01:00
Lukas
75778884bd Hide top bar in empty state and animate it in with first combatant
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 18s
The turn navigation bar is now hidden when no combatants exist, keeping
the empty state clean. It slides down from above when the first
combatant is added, synchronized with the action bar settling animation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:43:41 +01:00
Lukas
72d4f30e60 Center action bar in empty state for better onboarding UX
Replace the abstract + icon with the actual input field centered at the
optical center when no combatants exist. Animate the transition in both
directions: settling down when the first combatant is added, rising up
when all combatants are removed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:29:51 +01:00
Lukas
96b37d4bdd Color player character names instead of left border
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 18s
Player characters now show their chosen color on their name text
rather than as a left border glow. Left border is reserved for
active row (accent) and concentration (purple).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:52:09 +01:00
Lukas
76ca78c169 Improve player modals: Escape to close, trash icon for delete
Both player management and create/edit modals now close on Escape.
Delete player character button uses Trash2 icon instead of X to
distinguish permanent deletion from dismissal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:51:58 +01:00
Lukas
b0c27b8ab9 Add red hover effect to destructive buttons
ConfirmButton now shows hover:text-hover-destructive in its default
state. Source manager delete buttons and Clear All get matching
destructive hover styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:51:34 +01:00
20 changed files with 1662 additions and 534 deletions

View File

@@ -3,8 +3,13 @@ import {
rollInitiativeUseCase, rollInitiativeUseCase,
} from "@initiative/application"; } from "@initiative/application";
import type { CombatantId, Creature, CreatureId } from "@initiative/domain"; import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
import { Plus } from "lucide-react"; import {
import { useCallback, useEffect, useRef, useState } from "react"; useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { ActionBar } from "./components/action-bar"; import { ActionBar } from "./components/action-bar";
import { CombatantRow } from "./components/combatant-row"; import { CombatantRow } from "./components/combatant-row";
import { CreatePlayerModal } from "./components/create-player-modal"; import { CreatePlayerModal } from "./components/create-player-modal";
@@ -22,6 +27,44 @@ function rollDice(): number {
return Math.floor(Math.random() * 20) + 1; return Math.floor(Math.random() * 20) + 1;
} }
function useActionBarAnimation(combatantCount: number) {
const wasEmptyRef = useRef(combatantCount === 0);
const [settling, setSettling] = useState(false);
const [rising, setRising] = useState(false);
const [topBarExiting, setTopBarExiting] = useState(false);
useLayoutEffect(() => {
const nowEmpty = combatantCount === 0;
if (wasEmptyRef.current && !nowEmpty) {
setSettling(true);
} else if (!wasEmptyRef.current && nowEmpty) {
setRising(true);
setTopBarExiting(true);
}
wasEmptyRef.current = nowEmpty;
}, [combatantCount]);
const empty = combatantCount === 0;
const risingClass = rising ? " animate-rise-to-center" : "";
const settlingClass = settling ? " animate-settle-to-bottom" : "";
const topBarClass = settling
? " animate-slide-down-in"
: topBarExiting
? " absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
: "";
const showTopBar = !empty || topBarExiting;
return {
risingClass,
settlingClass,
topBarClass,
showTopBar,
onSettleEnd: () => setSettling(false),
onRiseEnd: () => setRising(false),
onTopBarExitEnd: () => setTopBarExiting(false),
};
}
export function App() { export function App() {
const { const {
encounter, encounter,
@@ -171,6 +214,7 @@ export function App() {
}, []); }, []);
const actionBarInputRef = useRef<HTMLInputElement>(null); const actionBarInputRef = useRef<HTMLInputElement>(null);
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
// Auto-scroll to the active combatant when the turn changes // Auto-scroll to the active combatant when the turn changes
const activeRowRef = useRef<HTMLDivElement>(null); const activeRowRef = useRef<HTMLDivElement>(null);
@@ -194,85 +238,116 @@ export function App() {
setSelectedCreatureId(active.creatureId as CreatureId); setSelectedCreatureId(active.creatureId as CreatureId);
}, [encounter.activeIndex, encounter.combatants, isLoaded]); }, [encounter.activeIndex, encounter.combatants, isLoaded]);
const isEmpty = encounter.combatants.length === 0;
const showRollAllInitiative = encounter.combatants.some(
(c) => c.creatureId != null && c.initiative == null,
);
return ( return (
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
<div className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0"> <div className="relative mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0">
{/* Turn Navigation — fixed at top */} {actionBarAnim.showTopBar && (
<div className="shrink-0 pt-8"> <div
<TurnNavigation className={`shrink-0 pt-8${actionBarAnim.topBarClass}`}
encounter={encounter} onAnimationEnd={actionBarAnim.onTopBarExitEnd}
onAdvanceTurn={advanceTurn} >
onRetreatTurn={retreatTurn} <TurnNavigation
onClearEncounter={clearEncounter} encounter={encounter}
onRollAllInitiative={handleRollAllInitiative} onAdvanceTurn={advanceTurn}
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)} onRetreatTurn={retreatTurn}
/> onClearEncounter={clearEncounter}
</div> />
{sourceManagerOpen && (
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
<SourceManager onCacheCleared={refreshCache} />
</div> </div>
)} )}
{/* Scrollable area — combatant list */} {isEmpty ? (
<div className="flex-1 overflow-y-auto min-h-0"> /* Empty state — ActionBar centered */
<div <div className="flex flex-1 items-center justify-center min-h-0 pb-[15%] pt-8">
className={`flex flex-col px-2 py-2${encounter.combatants.length === 0 ? " h-full items-center justify-center" : ""}`} <div
> className={`w-full${actionBarAnim.risingClass}`}
{encounter.combatants.length === 0 ? ( onAnimationEnd={actionBarAnim.onRiseEnd}
<button >
type="button" <ActionBar
onClick={() => actionBarInputRef.current?.focus()} onAddCombatant={addCombatant}
className="animate-breathe cursor-pointer text-muted-foreground transition-colors hover:text-primary" onAddFromBestiary={handleAddFromBestiary}
> bestiarySearch={search}
<Plus className="size-14" /> bestiaryLoaded={isLoaded}
</button> onViewStatBlock={handleViewStatBlock}
) : ( onBulkImport={handleBulkImport}
encounter.combatants.map((c, i) => ( bulkImportDisabled={bulkImport.state.status === "loading"}
<CombatantRow inputRef={actionBarInputRef}
key={c.id} playerCharacters={playerCharacters}
ref={i === encounter.activeIndex ? activeRowRef : null} onAddFromPlayerCharacter={addFromPlayerCharacter}
combatant={c} onManagePlayers={() => setManagementOpen(true)}
isActive={i === encounter.activeIndex} onRollAllInitiative={handleRollAllInitiative}
onRename={editCombatant} showRollAllInitiative={showRollAllInitiative}
onSetInitiative={setInitiative} onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
onRemove={removeCombatant} autoFocus
onSetHp={setHp} />
onAdjustHp={adjustHp} </div>
onSetAc={setAc}
onToggleCondition={toggleCondition}
onToggleConcentration={toggleConcentration}
onShowStatBlock={
c.creatureId
? () => handleCombatantStatBlock(c.creatureId as string)
: undefined
}
onRollInitiative={
c.creatureId ? handleRollInitiative : undefined
}
/>
))
)}
</div> </div>
</div> ) : (
<>
{sourceManagerOpen && (
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
<SourceManager onCacheCleared={refreshCache} />
</div>
)}
{/* Action Bar — fixed at bottom */} {/* Scrollable area — combatant list */}
<div className="shrink-0 pb-8"> <div className="flex-1 overflow-y-auto min-h-0">
<ActionBar <div className="flex flex-col px-2 py-2">
onAddCombatant={addCombatant} {encounter.combatants.map((c, i) => (
onAddFromBestiary={handleAddFromBestiary} <CombatantRow
bestiarySearch={search} key={c.id}
bestiaryLoaded={isLoaded} ref={i === encounter.activeIndex ? activeRowRef : null}
onViewStatBlock={handleViewStatBlock} combatant={c}
onBulkImport={handleBulkImport} isActive={i === encounter.activeIndex}
bulkImportDisabled={bulkImport.state.status === "loading"} onRename={editCombatant}
inputRef={actionBarInputRef} onSetInitiative={setInitiative}
playerCharacters={playerCharacters} onRemove={removeCombatant}
onAddFromPlayerCharacter={addFromPlayerCharacter} onSetHp={setHp}
onManagePlayers={() => setManagementOpen(true)} onAdjustHp={adjustHp}
/> onSetAc={setAc}
</div> onToggleCondition={toggleCondition}
onToggleConcentration={toggleConcentration}
onShowStatBlock={
c.creatureId
? () => handleCombatantStatBlock(c.creatureId as string)
: undefined
}
onRollInitiative={
c.creatureId ? handleRollInitiative : undefined
}
/>
))}
</div>
</div>
{/* Action Bar — fixed at bottom */}
<div
className={`shrink-0 pb-8${actionBarAnim.settlingClass}`}
onAnimationEnd={actionBarAnim.onSettleEnd}
>
<ActionBar
onAddCombatant={addCombatant}
onAddFromBestiary={handleAddFromBestiary}
bestiarySearch={search}
bestiaryLoaded={isLoaded}
onViewStatBlock={handleViewStatBlock}
onBulkImport={handleBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"}
inputRef={actionBarInputRef}
playerCharacters={playerCharacters}
onAddFromPlayerCharacter={addFromPlayerCharacter}
onManagePlayers={() => setManagementOpen(true)}
onRollAllInitiative={handleRollAllInitiative}
showRollAllInitiative={showRollAllInitiative}
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
/>
</div>
</>
)}
</div> </div>
{/* Pinned Stat Block Panel (left) */} {/* Pinned Stat Block Panel (left) */}

View File

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

View File

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

View File

@@ -28,9 +28,7 @@ export function BulkImportPrompt({
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-sm text-green-400"> <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 All sources loaded
</div> </div>
<Button size="sm" onClick={onDone}> <Button onClick={onDone}>Done</Button>
Done
</Button>
</div> </div>
); );
} }
@@ -42,9 +40,7 @@ export function BulkImportPrompt({
Loaded {importState.completed}/{importState.total} sources ( Loaded {importState.completed}/{importState.total} sources (
{importState.failed} failed) {importState.failed} failed)
</div> </div>
<Button size="sm" onClick={onDone}> <Button onClick={onDone}>Done</Button>
Done
</Button>
</div> </div>
); );
} }
@@ -103,11 +99,7 @@ export function BulkImportPrompt({
/> />
</div> </div>
<Button <Button onClick={() => onStartImport(baseUrl)} disabled={isDisabled}>
size="sm"
onClick={() => onStartImport(baseUrl)}
disabled={isDisabled}
>
Load All Load All
</Button> </Button>
</div> </div>

View File

@@ -49,11 +49,13 @@ function EditableName({
combatantId, combatantId,
onRename, onRename,
onShowStatBlock, onShowStatBlock,
color,
}: { }: {
name: string; name: string;
combatantId: CombatantId; combatantId: CombatantId;
onRename: (id: CombatantId, newName: string) => void; onRename: (id: CombatantId, newName: string) => void;
onShowStatBlock?: () => void; onShowStatBlock?: () => void;
color?: string;
}) { }) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(name); const [draft, setDraft] = useState(name);
@@ -143,6 +145,7 @@ function EditableName({
onTouchCancel={cancelLongPress} onTouchCancel={cancelLongPress}
onTouchMove={cancelLongPress} onTouchMove={cancelLongPress}
className="truncate text-left text-sm text-foreground cursor-text hover:text-hover-neutral transition-colors" className="truncate text-left text-sm text-foreground cursor-text hover:text-hover-neutral transition-colors"
style={color ? { color } : undefined}
> >
{name} {name}
</button> </button>
@@ -482,10 +485,9 @@ export function CombatantRow({
} }
}, [combatant.isConcentrating]); }, [combatant.isConcentrating]);
const pcColor = const pcColor = combatant.color
combatant.color && !isActive && !combatant.isConcentrating ? PLAYER_COLOR_HEX[combatant.color as keyof typeof PLAYER_COLOR_HEX]
? PLAYER_COLOR_HEX[combatant.color as keyof typeof PLAYER_COLOR_HEX] : undefined;
: undefined;
return ( return (
/* biome-ignore lint/a11y/noStaticElementInteractions: role="button" is set conditionally when onShowStatBlock exists */ /* biome-ignore lint/a11y/noStaticElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
@@ -499,7 +501,6 @@ export function CombatantRow({
isPulsing && "animate-concentration-pulse", isPulsing && "animate-concentration-pulse",
onShowStatBlock && "cursor-pointer", onShowStatBlock && "cursor-pointer",
)} )}
style={pcColor ? { borderLeftColor: pcColor } : undefined}
onClick={onShowStatBlock} onClick={onShowStatBlock}
onKeyDown={ onKeyDown={
onShowStatBlock ? activateOnKeyDown(onShowStatBlock) : undefined onShowStatBlock ? activateOnKeyDown(onShowStatBlock) : undefined
@@ -566,6 +567,7 @@ export function CombatantRow({
combatantId={id} combatantId={id}
onRename={onRename} onRename={onRename}
onShowStatBlock={onShowStatBlock} onShowStatBlock={onShowStatBlock}
color={pcColor}
/> />
<ConditionTags <ConditionTags
conditions={combatant.conditions} conditions={combatant.conditions}

View File

@@ -53,6 +53,15 @@ export function CreatePlayerModal({
} }
}, [open, playerCharacter]); }, [open, playerCharacter]);
useEffect(() => {
if (!open) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, onClose]);
if (!open) return null; if (!open) return null;
const handleSubmit = (e: FormEvent) => { const handleSubmit = (e: FormEvent) => {
@@ -91,13 +100,14 @@ export function CreatePlayerModal({
<h2 className="text-lg font-semibold text-foreground"> <h2 className="text-lg font-semibold text-foreground">
{isEdit ? "Edit Player" : "Create Player"} {isEdit ? "Edit Player" : "Create Player"}
</h2> </h2>
<button <Button
type="button" variant="ghost"
size="icon"
onClick={onClose} onClick={onClose}
className="text-muted-foreground hover:text-foreground transition-colors" className="text-muted-foreground"
> >
<X size={20} /> <X size={20} />
</button> </Button>
</div> </div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4"> <form onSubmit={handleSubmit} className="flex flex-col gap-4">

View File

@@ -6,7 +6,6 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input"; import { Input } from "./ui/input";
interface HpAdjustPopoverProps { interface HpAdjustPopoverProps {
@@ -109,30 +108,26 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
}} }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
/> />
<Button <button
type="button" type="button"
variant="ghost"
size="icon"
disabled={!isValid} 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)} onClick={() => applyDelta(-1)}
title="Apply damage" title="Apply damage"
aria-label="Apply damage" aria-label="Apply damage"
> >
<Sword size={14} /> <Sword size={14} />
</Button> </button>
<Button <button
type="button" type="button"
variant="ghost"
size="icon"
disabled={!isValid} 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)} onClick={() => applyDelta(1)}
title="Apply healing" title="Apply healing"
aria-label="Apply healing" aria-label="Apply healing"
> >
<Heart size={14} /> <Heart size={14} />
</Button> </button>
</div> </div>
</div> </div>
); );

View File

@@ -3,7 +3,8 @@ import type {
PlayerCharacterId, PlayerCharacterId,
PlayerIcon, PlayerIcon,
} from "@initiative/domain"; } from "@initiative/domain";
import { Pencil, Plus, X } from "lucide-react"; import { Pencil, Plus, Trash2, X } from "lucide-react";
import { useEffect } from "react";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map"; import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { ConfirmButton } from "./ui/confirm-button"; import { ConfirmButton } from "./ui/confirm-button";
@@ -25,6 +26,15 @@ export function PlayerManagement({
onDelete, onDelete,
onCreate, onCreate,
}: PlayerManagementProps) { }: PlayerManagementProps) {
useEffect(() => {
if (!open) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, onClose]);
if (!open) return null; if (!open) return null;
return ( return (
@@ -42,19 +52,20 @@ export function PlayerManagement({
<h2 className="text-lg font-semibold text-foreground"> <h2 className="text-lg font-semibold text-foreground">
Player Characters Player Characters
</h2> </h2>
<button <Button
type="button" variant="ghost"
size="icon"
onClick={onClose} onClick={onClose}
className="text-muted-foreground hover:text-foreground transition-colors" className="text-muted-foreground"
> >
<X size={20} /> <X size={20} />
</button> </Button>
</div> </div>
{characters.length === 0 ? ( {characters.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-8 text-center"> <div className="flex flex-col items-center gap-3 py-8 text-center">
<p className="text-muted-foreground">No player characters yet</p> <p className="text-muted-foreground">No player characters yet</p>
<Button onClick={onCreate} size="sm"> <Button onClick={onCreate}>
<Plus size={16} /> <Plus size={16} />
Create your first player character Create your first player character
</Button> </Button>
@@ -68,7 +79,7 @@ export function PlayerManagement({
return ( return (
<div <div
key={pc.id} 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 && (
<Icon size={18} style={{ color }} className="shrink-0" /> <Icon size={18} style={{ color }} className="shrink-0" />
@@ -82,25 +93,27 @@ export function PlayerManagement({
<span className="text-xs tabular-nums text-muted-foreground"> <span className="text-xs tabular-nums text-muted-foreground">
HP {pc.maxHp} HP {pc.maxHp}
</span> </span>
<button <Button
type="button" variant="ghost"
size="icon-sm"
onClick={() => onEdit(pc)} onClick={() => onEdit(pc)}
className="text-muted-foreground hover:text-foreground transition-colors" className="text-muted-foreground"
title="Edit" title="Edit"
> >
<Pencil size={14} /> <Pencil size={14} />
</button> </Button>
<ConfirmButton <ConfirmButton
icon={<X size={14} />} icon={<Trash2 size={14} />}
label="Delete player character" label="Delete player character"
onConfirm={() => onDelete(pc.id)} onConfirm={() => onDelete(pc.id)}
className="h-6 w-6 text-muted-foreground" size="icon-sm"
className="text-muted-foreground"
/> />
</div> </div>
); );
})} })}
<div className="mt-2 flex justify-end"> <div className="mt-2 flex justify-end">
<Button onClick={onCreate} size="sm" variant="ghost"> <Button onClick={onCreate} variant="ghost">
<Plus size={16} /> <Plus size={16} />
Add Add
</Button> </Button>

View File

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

View File

@@ -47,7 +47,11 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
<span className="text-sm font-semibold text-foreground"> <span className="text-sm font-semibold text-foreground">
Cached Sources Cached Sources
</span> </span>
<Button size="sm" variant="outline" onClick={handleClearAll}> <Button
variant="outline"
className="hover:text-hover-destructive hover:border-hover-destructive"
onClick={handleClearAll}
>
<Trash2 className="mr-1 h-3 w-3" /> <Trash2 className="mr-1 h-3 w-3" />
Clear All Clear All
</Button> </Button>
@@ -69,7 +73,7 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
<button <button
type="button" type="button"
onClick={() => handleClearSource(source.sourceCode)} onClick={() => handleClearSource(source.sourceCode)}
className="text-muted-foreground hover:text-hover-danger" className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-hover-destructive-bg hover:text-hover-destructive"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>

View File

@@ -8,6 +8,7 @@ import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
import { BulkImportPrompt } from "./bulk-import-prompt.js"; import { BulkImportPrompt } from "./bulk-import-prompt.js";
import { SourceFetchPrompt } from "./source-fetch-prompt.js"; import { SourceFetchPrompt } from "./source-fetch-prompt.js";
import { StatBlock } from "./stat-block.js"; import { StatBlock } from "./stat-block.js";
import { Button } from "./ui/button.js";
interface StatBlockPanelProps { interface StatBlockPanelProps {
creatureId: CreatureId | null; creatureId: CreatureId | null;
@@ -81,36 +82,39 @@ function PanelHeader({
<div className="flex items-center justify-between border-b border-border px-4 py-2"> <div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{panelRole === "browse" && ( {panelRole === "browse" && (
<button <Button
type="button" variant="ghost"
size="icon-sm"
onClick={onToggleFold} onClick={onToggleFold}
className="text-muted-foreground hover:text-hover-neutral" className="text-muted-foreground"
aria-label="Fold stat block panel" aria-label="Fold stat block panel"
> >
<PanelRightClose className="h-4 w-4" /> <PanelRightClose className="h-4 w-4" />
</button> </Button>
)} )}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{panelRole === "browse" && showPinButton && ( {panelRole === "browse" && showPinButton && (
<button <Button
type="button" variant="ghost"
size="icon-sm"
onClick={onPin} onClick={onPin}
className="text-muted-foreground hover:text-hover-neutral" className="text-muted-foreground"
aria-label="Pin creature" aria-label="Pin creature"
> >
<Pin className="h-4 w-4" /> <Pin className="h-4 w-4" />
</button> </Button>
)} )}
{panelRole === "pinned" && ( {panelRole === "pinned" && (
<button <Button
type="button" variant="ghost"
size="icon-sm"
onClick={onUnpin} onClick={onUnpin}
className="text-muted-foreground hover:text-hover-neutral" className="text-muted-foreground"
aria-label="Unpin creature" aria-label="Unpin creature"
> >
<PinOff className="h-4 w-4" /> <PinOff className="h-4 w-4" />
</button> </Button>
)} )}
</div> </div>
</div> </div>
@@ -195,14 +199,15 @@ function MobileDrawer({
{...handlers} {...handlers}
> >
<div className="flex items-center justify-between border-b border-border px-4 py-2"> <div className="flex items-center justify-between border-b border-border px-4 py-2">
<button <Button
type="button" variant="ghost"
size="icon-sm"
onClick={onDismiss} onClick={onDismiss}
className="text-muted-foreground hover:text-hover-neutral" className="text-muted-foreground"
aria-label="Fold stat block panel" aria-label="Fold stat block panel"
> >
<PanelRightClose className="h-4 w-4" /> <PanelRightClose className="h-4 w-4" />
</button> </Button>
</div> </div>
<div className="h-[calc(100%-41px)] overflow-y-auto p-4"> <div className="h-[calc(100%-41px)] overflow-y-auto p-4">
{children} {children}

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ interface ConfirmButtonProps {
readonly onConfirm: () => void; readonly onConfirm: () => void;
readonly icon: ReactElement; readonly icon: ReactElement;
readonly label: string; readonly label: string;
readonly size?: "icon" | "icon-sm";
readonly className?: string; readonly className?: string;
readonly disabled?: boolean; readonly disabled?: boolean;
} }
@@ -23,6 +24,7 @@ export function ConfirmButton({
onConfirm, onConfirm,
icon, icon,
label, label,
size = "icon",
className, className,
disabled, disabled,
}: ConfirmButtonProps) { }: ConfirmButtonProps) {
@@ -94,11 +96,12 @@ export function ConfirmButton({
<div ref={wrapperRef} className="inline-flex"> <div ref={wrapperRef} className="inline-flex">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size={size}
className={cn( className={cn(
className, className,
isConfirming && isConfirming
"bg-destructive text-primary-foreground rounded-md animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground", ? "bg-destructive text-primary-foreground rounded-md animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground"
: "hover:text-hover-destructive",
)} )}
onClick={handleClick} onClick={handleClick}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}

View File

@@ -0,0 +1,72 @@
import { EllipsisVertical } from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { Button } from "./button";
export interface OverflowMenuItem {
readonly icon: ReactNode;
readonly label: string;
readonly onClick: () => void;
readonly disabled?: boolean;
}
interface OverflowMenuProps {
readonly items: readonly OverflowMenuItem[];
}
export function OverflowMenu({ items }: OverflowMenuProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleMouseDown(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
return (
<div ref={ref} className="relative">
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-hover-neutral"
onClick={() => setOpen((o) => !o)}
aria-label="More actions"
title="More actions"
>
<EllipsisVertical className="h-5 w-5" />
</Button>
{open && (
<div className="absolute bottom-full right-0 z-50 mb-1 min-w-48 rounded-md border border-border bg-card py-1 shadow-lg">
{items.map((item) => (
<button
key={item.label}
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-hover-neutral-bg disabled:pointer-events-none disabled:opacity-50"
disabled={item.disabled}
onClick={() => {
item.onClick();
setOpen(false);
}}
>
{item.icon}
{item.label}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -16,7 +16,7 @@
--color-hover-neutral: var(--color-primary); --color-hover-neutral: var(--color-primary);
--color-hover-action: var(--color-primary); --color-hover-action: var(--color-primary);
--color-hover-destructive: var(--color-destructive); --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-action-bg: var(--color-muted);
--color-hover-destructive-bg: transparent; --color-hover-destructive-bg: transparent;
--radius-sm: 0.25rem; --radius-sm: 0.25rem;
@@ -80,20 +80,73 @@
} }
} }
@keyframes breathe { @keyframes settle-to-bottom {
0%, from {
100% { transform: translateY(-40vh);
opacity: 0.4; opacity: 0;
scale: 0.9;
} }
50% { 40% {
opacity: 1;
}
to {
transform: translateY(0);
opacity: 1; opacity: 1;
scale: 1.1;
} }
} }
@utility animate-breathe { @utility animate-settle-to-bottom {
animation: breathe 3s ease-in-out infinite; animation: settle-to-bottom 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
@keyframes rise-to-center {
from {
transform: translateY(40vh);
opacity: 0;
}
40% {
opacity: 1;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@utility animate-rise-to-center {
animation: rise-to-center 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
@keyframes slide-down-in {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@utility animate-slide-down-in {
animation: slide-down-in 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
@keyframes slide-up-out {
from {
transform: translateY(0);
opacity: 1;
}
60% {
opacity: 0;
}
to {
transform: translateY(-100%);
opacity: 0;
}
}
@utility animate-slide-up-out {
animation: slide-up-out 700ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
} }
@custom-variant pointer-coarse (@media (pointer: coarse)); @custom-variant pointer-coarse (@media (pointer: coarse));

View File

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

View File

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

View File

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