From bd398080008349b47726d0016f4b03587f453833 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 13 Mar 2026 16:31:25 +0100 Subject: [PATCH] Declutter action bars: overflow menu, browse toggle, conditional D20 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/src/App.tsx | 11 +- .../__tests__/turn-navigation.test.tsx | 20 - apps/web/src/components/action-bar.tsx | 680 ++++++++++-------- apps/web/src/components/turn-navigation.tsx | 43 +- apps/web/src/components/ui/overflow-menu.tsx | 72 ++ .../plans/2026-03-13-declutter-action-bars.md | 536 ++++++++++++++ .../2026-03-13-action-bars-and-buttons.md | 176 +++++ 7 files changed, 1163 insertions(+), 375 deletions(-) create mode 100644 apps/web/src/components/ui/overflow-menu.tsx create mode 100644 docs/agents/plans/2026-03-13-declutter-action-bars.md create mode 100644 docs/agents/research/2026-03-13-action-bars-and-buttons.md diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 9192dd8..a4b7885 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -239,6 +239,9 @@ export function App() { }, [encounter.activeIndex, encounter.combatants, isLoaded]); const isEmpty = encounter.combatants.length === 0; + const showRollAllInitiative = encounter.combatants.some( + (c) => c.creatureId != null && c.initiative == null, + ); return (
@@ -253,8 +256,6 @@ export function App() { onAdvanceTurn={advanceTurn} onRetreatTurn={retreatTurn} onClearEncounter={clearEncounter} - onRollAllInitiative={handleRollAllInitiative} - onOpenSourceManager={() => setSourceManagerOpen((o) => !o)} />
)} @@ -278,6 +279,9 @@ export function App() { playerCharacters={playerCharacters} onAddFromPlayerCharacter={addFromPlayerCharacter} onManagePlayers={() => setManagementOpen(true)} + onRollAllInitiative={handleRollAllInitiative} + showRollAllInitiative={showRollAllInitiative} + onOpenSourceManager={() => setSourceManagerOpen((o) => !o)} autoFocus /> @@ -337,6 +341,9 @@ export function App() { playerCharacters={playerCharacters} onAddFromPlayerCharacter={addFromPlayerCharacter} onManagePlayers={() => setManagementOpen(true)} + onRollAllInitiative={handleRollAllInitiative} + showRollAllInitiative={showRollAllInitiative} + onOpenSourceManager={() => setSourceManagerOpen((o) => !o)} /> diff --git a/apps/web/src/components/__tests__/turn-navigation.test.tsx b/apps/web/src/components/__tests__/turn-navigation.test.tsx index f051f41..9fff37d 100644 --- a/apps/web/src/components/__tests__/turn-navigation.test.tsx +++ b/apps/web/src/components/__tests__/turn-navigation.test.tsx @@ -26,8 +26,6 @@ function renderNav(overrides: Partial = {}) { onAdvanceTurn={vi.fn()} onRetreatTurn={vi.fn()} onClearEncounter={vi.fn()} - onRollAllInitiative={vi.fn()} - onOpenSourceManager={vi.fn()} />, ); } @@ -72,8 +70,6 @@ describe("TurnNavigation", () => { onAdvanceTurn={vi.fn()} onRetreatTurn={vi.fn()} onClearEncounter={vi.fn()} - onRollAllInitiative={vi.fn()} - onOpenSourceManager={vi.fn()} />, ); expect(screen.getByText("R2")).toBeInTheDocument(); @@ -88,8 +84,6 @@ describe("TurnNavigation", () => { onAdvanceTurn={vi.fn()} onRetreatTurn={vi.fn()} onClearEncounter={vi.fn()} - onRollAllInitiative={vi.fn()} - onOpenSourceManager={vi.fn()} />, ); expect(screen.getByText("R3")).toBeInTheDocument(); @@ -110,8 +104,6 @@ describe("TurnNavigation", () => { onAdvanceTurn={vi.fn()} onRetreatTurn={vi.fn()} onClearEncounter={vi.fn()} - onRollAllInitiative={vi.fn()} - onOpenSourceManager={vi.fn()} />, ); expect(screen.getByText("Goblin")).toBeInTheDocument(); @@ -129,8 +121,6 @@ describe("TurnNavigation", () => { onAdvanceTurn={vi.fn()} onRetreatTurn={vi.fn()} onClearEncounter={vi.fn()} - onRollAllInitiative={vi.fn()} - onOpenSourceManager={vi.fn()} />, ); expect(screen.getByText("Conjurer")).toBeInTheDocument(); @@ -173,16 +163,6 @@ describe("TurnNavigation", () => { expect( screen.getByRole("button", { name: "Next turn" }), ).toBeInTheDocument(); - expect( - screen.getByRole("button", { - name: "Roll all initiative", - }), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { - name: "Manage cached sources", - }), - ).toBeInTheDocument(); }); it("renders a 40-character name without truncation class issues", () => { diff --git a/apps/web/src/components/action-bar.tsx b/apps/web/src/components/action-bar.tsx index 7c98450..fc9aec3 100644 --- a/apps/web/src/components/action-bar.tsx +++ b/apps/web/src/components/action-bar.tsx @@ -1,16 +1,22 @@ import type { PlayerCharacter, PlayerIcon } from "@initiative/domain"; -import { Check, Eye, Import, Minus, Plus, Users } from "lucide-react"; import { - type FormEvent, - type RefObject, - useEffect, - useRef, - useState, -} from "react"; + Check, + Eye, + EyeOff, + Import, + Library, + Minus, + Plus, + Users, +} from "lucide-react"; +import { type FormEvent, type RefObject, useState } from "react"; import type { SearchResult } from "../hooks/use-bestiary.js"; +import { cn } from "../lib/utils.js"; +import { D20Icon } from "./d20-icon.js"; import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map"; import { Button } from "./ui/button.js"; import { Input } from "./ui/input.js"; +import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js"; interface QueuedCreature { result: SearchResult; @@ -32,6 +38,9 @@ interface ActionBarProps { playerCharacters?: readonly PlayerCharacter[]; onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void; onManagePlayers?: () => void; + onRollAllInitiative?: () => void; + showRollAllInitiative?: boolean; + onOpenSourceManager?: () => void; autoFocus?: boolean; } @@ -39,6 +48,201 @@ function creatureKey(r: SearchResult): string { 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 ( +
+ +
+ {pcMatches.length > 0 && ( + <> +
+ Players +
+
    + {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 ( +
  • + +
  • + ); + })} +
+ + )} + {suggestions.length > 0 && ( +
    + {suggestions.map((result, i) => { + const key = creatureKey(result); + const isQueued = + queued !== null && creatureKey(queued.result) === key; + return ( +
  • + + + {queued.count} + + + + + ) : ( + result.sourceDisplayName + )} + + +
  • + ); + })} +
+ )} +
+
+ ); +} + +function buildOverflowItems(opts: { + onManagePlayers?: () => void; + onOpenSourceManager?: () => void; + bestiaryLoaded: boolean; + onBulkImport?: () => void; + bulkImportDisabled?: boolean; +}): OverflowMenuItem[] { + const items: OverflowMenuItem[] = []; + if (opts.onManagePlayers) { + items.push({ + icon: , + label: "Player Characters", + onClick: opts.onManagePlayers, + }); + } + if (opts.onOpenSourceManager) { + items.push({ + icon: , + label: "Manage Sources", + onClick: opts.onOpenSourceManager, + }); + } + if (opts.bestiaryLoaded && opts.onBulkImport) { + items.push({ + icon: , + label: "Bulk Import", + onClick: opts.onBulkImport, + disabled: opts.bulkImportDisabled, + }); + } + return items; +} + export function ActionBar({ onAddCombatant, onAddFromBestiary, @@ -51,6 +255,9 @@ export function ActionBar({ playerCharacters, onAddFromPlayerCharacter, onManagePlayers, + onRollAllInitiative, + showRollAllInitiative, + onOpenSourceManager, autoFocus, }: ActionBarProps) { const [nameInput, setNameInput] = useState(""); @@ -61,14 +268,7 @@ export function ActionBar({ const [customInit, setCustomInit] = useState(""); const [customAc, setCustomAc] = useState(""); const [customMaxHp, setCustomMaxHp] = useState(""); - - // Stat block viewer: separate dropdown - const [viewerOpen, setViewerOpen] = useState(false); - const [viewerQuery, setViewerQuery] = useState(""); - const [viewerResults, setViewerResults] = useState([]); - const [viewerIndex, setViewerIndex] = useState(-1); - const viewerRef = useRef(null); - const viewerInputRef = useRef(null); + const [browseMode, setBrowseMode] = useState(false); const clearCustomFields = () => { setCustomInit(""); @@ -76,16 +276,20 @@ export function ActionBar({ setCustomMaxHp(""); }; + const clearInput = () => { + setNameInput(""); + setSuggestions([]); + setPcMatches([]); + setQueued(null); + setSuggestionIndex(-1); + }; + const confirmQueued = () => { if (!queued) return; for (let i = 0; i < queued.count; i++) { onAddFromBestiary(queued.result); } - setQueued(null); - setNameInput(""); - setSuggestions([]); - setPcMatches([]); - setSuggestionIndex(-1); + clearInput(); }; const parseNum = (v: string): number | undefined => { @@ -96,6 +300,7 @@ export function ActionBar({ const handleAdd = (e: FormEvent) => { e.preventDefault(); + if (browseMode) return; if (queued) { confirmQueued(); return; @@ -115,9 +320,11 @@ export function ActionBar({ clearCustomFields(); }; - const handleNameChange = (value: string) => { - setNameInput(value); - setSuggestionIndex(-1); + const handleBrowseSearch = (value: string) => { + setSuggestions(value.length >= 2 ? bestiarySearch(value) : []); + }; + + const handleAddSearch = (value: string) => { let newSuggestions: SearchResult[] = []; let newPcMatches: PlayerCharacter[] = []; if (value.length >= 2) { @@ -146,6 +353,16 @@ export function ActionBar({ } }; + const handleNameChange = (value: string) => { + setNameInput(value); + setSuggestionIndex(-1); + if (browseMode) { + handleBrowseSearch(value); + } else { + handleAddSearch(value); + } + }; + const handleClickSuggestion = (result: SearchResult) => { const key = creatureKey(result); if (queued && creatureKey(queued.result) === key) { @@ -178,74 +395,50 @@ export function ActionBar({ e.preventDefault(); handleEnter(); } else if (e.key === "Escape") { - setQueued(null); - setSuggestionIndex(-1); - setSuggestions([]); - setPcMatches([]); + clearInput(); } }; - // Stat block viewer dropdown handlers - const openViewer = () => { - setViewerOpen(true); - setViewerQuery(""); - setViewerResults([]); - setViewerIndex(-1); - requestAnimationFrame(() => viewerInputRef.current?.focus()); - }; - - const closeViewer = () => { - setViewerOpen(false); - setViewerQuery(""); - setViewerResults([]); - setViewerIndex(-1); - }; - - const handleViewerQueryChange = (value: string) => { - setViewerQuery(value); - setViewerIndex(-1); - if (value.length >= 2) { - setViewerResults(bestiarySearch(value)); - } else { - setViewerResults([]); - } - }; - - const handleViewerSelect = (result: SearchResult) => { - onViewStatBlock?.(result); - closeViewer(); - }; - - const handleViewerKeyDown = (e: React.KeyboardEvent) => { + const handleBrowseKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { - closeViewer(); + setBrowseMode(false); + clearInput(); return; } - if (viewerResults.length === 0) return; - + if (suggestions.length === 0) return; if (e.key === "ArrowDown") { 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") { e.preventDefault(); - setViewerIndex((i) => (i > 0 ? i - 1 : viewerResults.length - 1)); - } else if (e.key === "Enter" && viewerIndex >= 0) { + setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1)); + } else if (e.key === "Enter" && suggestionIndex >= 0) { e.preventDefault(); - handleViewerSelect(viewerResults[viewerIndex]); + onViewStatBlock?.(suggestions[suggestionIndex]); + setBrowseMode(false); + clearInput(); } }; - // Close viewer on outside click - useEffect(() => { - if (!viewerOpen) return; - function handleClickOutside(e: MouseEvent) { - if (viewerRef.current && !viewerRef.current.contains(e.target as Node)) { - closeViewer(); - } - } - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [viewerOpen]); + const handleBrowseSelect = (result: SearchResult) => { + onViewStatBlock?.(result); + setBrowseMode(false); + clearInput(); + }; + + const toggleBrowseMode = () => { + setBrowseMode((m) => !m); + clearInput(); + clearCustomFields(); + }; + + const overflowItems = buildOverflowItems({ + onManagePlayers, + onOpenSourceManager, + bestiaryLoaded, + onBulkImport, + bulkImportDisabled, + }); return (
@@ -253,163 +446,85 @@ export function ActionBar({ onSubmit={handleAdd} className="relative flex flex-1 items-center gap-2" > -
- handleNameChange(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="+ Add combatants" - className="max-w-xs" - autoFocus={autoFocus} - /> - {hasSuggestions && ( -
+
+
+ handleNameChange(e.target.value)} + onKeyDown={browseMode ? handleBrowseKeyDown : handleKeyDown} + placeholder={ + browseMode ? "Search stat blocks..." : "+ Add combatants" + } + className="pr-8" + autoFocus={autoFocus} + /> + {bestiaryLoaded && onViewStatBlock && ( -
- {pcMatches.length > 0 && ( - <> -
- Players -
-
    - {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 ( -
  • - -
  • - ); - })} -
- - )} - {suggestions.length > 0 && ( -
    - {suggestions.map((result, i) => { - const key = creatureKey(result); - const isQueued = - queued !== null && creatureKey(queued.result) === key; - return ( -
  • - - - {queued.count} - - - - - ) : ( - result.sourceDisplayName - )} - - -
  • - ); - })} -
- )} + )} + {browseMode && suggestions.length > 0 && ( +
+
    + {suggestions.map((result, i) => ( +
  • + +
  • + ))} +
-
- )} + )} + {!browseMode && hasSuggestions && ( + + )} +
- {nameInput.length >= 2 && !hasSuggestions && ( + {!browseMode && nameInput.length >= 2 && !hasSuggestions && (
)} - -
- {onManagePlayers && ( - - )} - {bestiaryLoaded && onViewStatBlock && ( -
- - {viewerOpen && ( -
-
- handleViewerQueryChange(e.target.value)} - onKeyDown={handleViewerKeyDown} - placeholder="Search stat blocks..." - className="w-full" - /> -
- {viewerResults.length > 0 && ( -
    - {viewerResults.map((result, i) => ( -
  • - -
  • - ))} -
- )} - {viewerQuery.length >= 2 && viewerResults.length === 0 && ( -
- No creatures found -
- )} -
- )} -
- )} - {bestiaryLoaded && onBulkImport && ( - - )} -
+ {!browseMode && nameInput.length >= 2 && !hasSuggestions && ( + + )} + {showRollAllInitiative && onRollAllInitiative && ( + + )} + {overflowItems.length > 0 && }
); diff --git a/apps/web/src/components/turn-navigation.tsx b/apps/web/src/components/turn-navigation.tsx index 3c404ca..7b3b3fc 100644 --- a/apps/web/src/components/turn-navigation.tsx +++ b/apps/web/src/components/turn-navigation.tsx @@ -1,6 +1,5 @@ import type { Encounter } from "@initiative/domain"; -import { Library, StepBack, StepForward, Trash2 } from "lucide-react"; -import { D20Icon } from "./d20-icon"; +import { StepBack, StepForward, Trash2 } from "lucide-react"; import { Button } from "./ui/button"; import { ConfirmButton } from "./ui/confirm-button"; @@ -9,8 +8,6 @@ interface TurnNavigationProps { onAdvanceTurn: () => void; onRetreatTurn: () => void; onClearEncounter: () => void; - onRollAllInitiative: () => void; - onOpenSourceManager: () => void; } export function TurnNavigation({ @@ -18,8 +15,6 @@ export function TurnNavigation({ onAdvanceTurn, onRetreatTurn, onClearEncounter, - onRollAllInitiative, - onOpenSourceManager, }: TurnNavigationProps) { const hasCombatants = encounter.combatants.length > 0; const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0; @@ -49,35 +44,13 @@ export function TurnNavigation({
-
- - - } - label="Clear encounter" - onConfirm={onClearEncounter} - disabled={!hasCombatants} - className="text-muted-foreground" - /> -
+ } + label="Clear encounter" + onConfirm={onClearEncounter} + disabled={!hasCombatants} + className="text-muted-foreground" + /> + {open && ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+ ); +} diff --git a/docs/agents/plans/2026-03-13-declutter-action-bars.md b/docs/agents/plans/2026-03-13-declutter-action-bars.md new file mode 100644 index 0000000..0b6a57d --- /dev/null +++ b/docs/agents/plans/2026-03-13-declutter-action-bars.md @@ -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 `` 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(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 ( +
+ + {open && ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+ ); +} +``` + +### 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 +
+ 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 && ( + + )} + {/* suggestion dropdown — behavior changes based on browseMode */} +
+``` + +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 && ( + <> + + {showRollAllInitiative && onRollAllInitiative && ( + + )} + +)} + +``` + +Build the `overflowItems` array from props: +```tsx +const overflowItems: OverflowMenuItem[] = []; +if (onManagePlayers) { + overflowItems.push({ + icon: , + label: "Player Characters", + onClick: onManagePlayers, + }); +} +if (onOpenSourceManager) { + overflowItems.push({ + icon: , + label: "Manage Sources", + onClick: onOpenSourceManager, + }); +} +if (bestiaryLoaded && onBulkImport) { + overflowItems.push({ + icon: , + 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 `` calls (empty state at ~line 269 and populated state at ~line 328): + +```tsx + setSourceManagerOpen((o) => !o)} +/> +``` + +#### [x] 3. Remove stale code +**File**: `apps/web/src/App.tsx` +**Changes**: +- The `onRollAllInitiative` and `onOpenSourceManager` props were already removed from `` in Phase 1 — verify no references remain +- Verify `sourceManagerOpen` state and the `` 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` diff --git a/docs/agents/research/2026-03-13-action-bars-and-buttons.md b/docs/agents/research/2026-03-13-action-bars-and-buttons.md new file mode 100644 index 0000000..5d7f05c --- /dev/null +++ b/docs/agents/research/2026-03-13-action-bars-and-buttons.md @@ -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**: Left–Center–Right 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.