diff --git a/CLAUDE.md b/CLAUDE.md index 2e0799a..1f9abb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,6 +77,8 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work: - Browser localStorage (existing adapter, updated to handle empty encounters) (023-clear-encounter) - N/A (no storage changes — purely presentational fix) (024-fix-hp-popover-overflow) - N/A (no storage changes — purely derived from existing bestiary data) (025-display-initiative) +- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Tailwind CSS v4, Lucide React (icons), Vite 6 (026-roll-initiative) +- N/A (no storage changes — existing localStorage persistence handles initiative via `setInitiativeUseCase`) (026-roll-initiative) ## Recent Changes - 003-remove-combatant: Added TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 77eb471..4727249 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,4 +1,8 @@ -import type { Creature } from "@initiative/domain"; +import { + rollAllInitiativeUseCase, + rollInitiativeUseCase, +} from "@initiative/application"; +import type { CombatantId, Creature, CreatureId } from "@initiative/domain"; import { useCallback, useEffect, useRef, useState } from "react"; import { ActionBar } from "./components/action-bar"; import { CombatantRow } from "./components/combatant-row"; @@ -7,6 +11,10 @@ import { TurnNavigation } from "./components/turn-navigation"; import { useBestiary } from "./hooks/use-bestiary"; import { useEncounter } from "./hooks/use-encounter"; +function rollDice(): number { + return Math.floor(Math.random() * 20) + 1; +} + export function App() { const { encounter, @@ -23,6 +31,7 @@ export function App() { toggleCondition, toggleConcentration, addFromBestiary, + makeStore, } = useEncounter(); const { search, getCreature, isLoaded } = useBestiary(); @@ -46,9 +55,7 @@ export function App() { const handleCombatantStatBlock = useCallback( (creatureId: string) => { - const creature = getCreature( - creatureId as import("@initiative/domain").CreatureId, - ); + const creature = getCreature(creatureId as CreatureId); if (creature) setSelectedCreature(creature); }, [getCreature], @@ -65,6 +72,17 @@ export function App() { [isLoaded, search], ); + const handleRollInitiative = useCallback( + (id: CombatantId) => { + rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature); + }, + [makeStore, getCreature], + ); + + const handleRollAllInitiative = useCallback(() => { + rollAllInitiativeUseCase(makeStore(), rollDice, getCreature); + }, [makeStore, getCreature]); + // Auto-scroll to the active combatant when the turn changes const activeRowRef = useRef(null); useEffect(() => { @@ -80,9 +98,7 @@ export function App() { if (!window.matchMedia("(min-width: 1024px)").matches) return; const active = encounter.combatants[encounter.activeIndex]; if (!active?.creatureId || !isLoaded) return; - const creature = getCreature( - active.creatureId as import("@initiative/domain").CreatureId, - ); + const creature = getCreature(active.creatureId as CreatureId); if (creature) setSelectedCreature(creature); }, [encounter.activeIndex, encounter.combatants, getCreature, isLoaded]); @@ -96,6 +112,7 @@ export function App() { onAdvanceTurn={advanceTurn} onRetreatTurn={retreatTurn} onClearEncounter={clearEncounter} + onRollAllInitiative={handleRollAllInitiative} /> @@ -132,6 +149,9 @@ export function App() { ? () => handleCombatantStatBlock(c.creatureId as string) : undefined } + onRollInitiative={ + c.creatureId ? handleRollInitiative : undefined + } /> )) )} diff --git a/apps/web/src/components/combatant-row.tsx b/apps/web/src/components/combatant-row.tsx index 93af558..6a736ec 100644 --- a/apps/web/src/components/combatant-row.tsx +++ b/apps/web/src/components/combatant-row.tsx @@ -8,6 +8,7 @@ import { type Ref, useCallback, useEffect, useRef, useState } from "react"; import { cn } from "../lib/utils"; import { ConditionPicker } from "./condition-picker"; import { ConditionTags } from "./condition-tags"; +import { D20Icon } from "./d20-icon"; import { HpAdjustPopover } from "./hp-adjust-popover"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; @@ -35,6 +36,7 @@ interface CombatantRowProps { onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void; onToggleConcentration: (id: CombatantId) => void; onShowStatBlock?: () => void; + onRollInitiative?: (id: CombatantId) => void; } function EditableName({ @@ -266,6 +268,100 @@ function AcDisplay({ ); } +function InitiativeDisplay({ + initiative, + combatantId, + dimmed, + onSetInitiative, + onRollInitiative, +}: { + initiative: number | undefined; + combatantId: CombatantId; + dimmed: boolean; + onSetInitiative: (id: CombatantId, value: number | undefined) => void; + onRollInitiative?: (id: CombatantId) => void; +}) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(initiative?.toString() ?? ""); + const inputRef = useRef(null); + + const commit = useCallback(() => { + if (draft === "") { + onSetInitiative(combatantId, undefined); + } else { + const n = Number.parseInt(draft, 10); + if (!Number.isNaN(n)) { + onSetInitiative(combatantId, n); + } + } + setEditing(false); + }, [draft, combatantId, onSetInitiative]); + + const startEditing = useCallback(() => { + setDraft(initiative?.toString() ?? ""); + setEditing(true); + requestAnimationFrame(() => inputRef.current?.select()); + }, [initiative]); + + if (editing) { + return ( + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === "Enter") commit(); + if (e.key === "Escape") setEditing(false); + }} + /> + ); + } + + // Empty + bestiary creature → d20 roll button + if (initiative === undefined && onRollInitiative) { + return ( + + ); + } + + // Has value → bold number, click to edit + // Empty + manual → "--" placeholder, click to edit + return ( + + ); +} + export function CombatantRow({ ref, combatant, @@ -279,6 +375,7 @@ export function CombatantRow({ onToggleCondition, onToggleConcentration, onShowStatBlock, + onRollInitiative, }: CombatantRowProps & { ref?: Ref }) { const { id, name, initiative, maxHp, currentHp } = combatant; const status = deriveHpStatus(currentHp, maxHp); @@ -345,26 +442,12 @@ export function CombatantRow({ {/* Initiative */} - { - const raw = e.target.value; - if (raw === "") { - onSetInitiative(id, undefined); - } else { - const n = Number.parseInt(raw, 10); - if (!Number.isNaN(n)) { - onSetInitiative(id, n); - } - } - }} + {/* Name */} diff --git a/apps/web/src/components/d20-icon.tsx b/apps/web/src/components/d20-icon.tsx new file mode 100644 index 0000000..b6f9ea8 --- /dev/null +++ b/apps/web/src/components/d20-icon.tsx @@ -0,0 +1,29 @@ +interface D20IconProps { + readonly className?: string; +} + +export function D20Icon({ className }: D20IconProps) { + return ( + + ); +} diff --git a/apps/web/src/components/turn-navigation.tsx b/apps/web/src/components/turn-navigation.tsx index 52c85c2..be130eb 100644 --- a/apps/web/src/components/turn-navigation.tsx +++ b/apps/web/src/components/turn-navigation.tsx @@ -1,5 +1,6 @@ import type { Encounter } from "@initiative/domain"; import { ChevronLeft, ChevronRight, Trash2 } from "lucide-react"; +import { D20Icon } from "./d20-icon"; import { Button } from "./ui/button"; interface TurnNavigationProps { @@ -7,6 +8,7 @@ interface TurnNavigationProps { onAdvanceTurn: () => void; onRetreatTurn: () => void; onClearEncounter: () => void; + onRollAllInitiative: () => void; } export function TurnNavigation({ @@ -14,6 +16,7 @@ export function TurnNavigation({ onAdvanceTurn, onRetreatTurn, onClearEncounter, + onRollAllInitiative, }: TurnNavigationProps) { const hasCombatants = encounter.combatants.length > 0; const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0; @@ -57,6 +60,16 @@ export function TurnNavigation({ Next +