7 Commits

Author SHA1 Message Date
Lukas
6584d8d064 Add advantage/disadvantage rolling for initiative
All checks were successful
CI / check (push) Successful in 1m23s
CI / build-image (push) Has been skipped
Right-click or long-press the d20 button (per-combatant or Roll All)
to open a context menu with Advantage and Disadvantage options.
Normal left-click behavior is unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:16:04 +01:00
Lukas
7f38cbab73 Preserve stat block panel collapsed state on turn advance
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 23:23:52 +01:00
Lukas
2971898f0c Add dark and light theme with OS preference support
All checks were successful
CI / check (push) Successful in 1m22s
CI / build-image (push) Successful in 36s
Follow OS color scheme by default, with a three-way toggle
(System / Light / Dark) in the kebab menu. Light theme uses warm,
neutral tones with soft card-to-background contrast. Semantic colors
(damage, healing, conditions) keep their hue across themes.

Closes #10

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 13:24:18 +01:00
Lukas
43780772f6 Improve bestiary icon UX and auto-update stat block on turn change
- Use Book/BookOpen icons to indicate stat block open state
- Bump combatant icons (bestiary + PC) from 14px to 16px
- Use text-foreground for bestiary icon visibility
- Auto-update stat block panel to active combatant's creature on turn advance
- Update bestiary spec edge case to reflect new behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 12:48:38 +01:00
Lukas
7b3dbe2069 Use ghost buttons for turn navigation to blend with top bar
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 12:18:31 +01:00
Lukas
827a3978e9 Show toast and open source panel when rolling initiative without loaded source
When a user clicks the d20 to roll initiative for a single combatant whose
bestiary source isn't cached, show an informative toast and open the stat
block panel so they can load the source directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 12:11:20 +01:00
Lukas
f024562a7d Auto-open stat block panel when adding first bestiary creature
When the side panel is in its initial closed state (not user-collapsed),
adding a combatant from the bestiary now opens the panel to show its
stat block. This makes the panel discoverable without overriding a
deliberate collapse.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 12:03:34 +01:00
23 changed files with 665 additions and 75 deletions

View File

@@ -7,6 +7,7 @@ import {
type Creature, type Creature,
type CreatureId, type CreatureId,
isDomainError, isDomainError,
type RollMode,
} from "@initiative/domain"; } from "@initiative/domain";
import { import {
useCallback, useCallback,
@@ -30,6 +31,7 @@ import { useBulkImport } from "./hooks/use-bulk-import";
import { useEncounter } from "./hooks/use-encounter"; import { useEncounter } from "./hooks/use-encounter";
import { usePlayerCharacters } from "./hooks/use-player-characters"; import { usePlayerCharacters } from "./hooks/use-player-characters";
import { useSidePanelState } from "./hooks/use-side-panel-state"; import { useSidePanelState } from "./hooks/use-side-panel-state";
import { useTheme } from "./hooks/use-theme";
import { cn } from "./lib/utils"; import { cn } from "./lib/utils";
function rollDice(): number { function rollDice(): number {
@@ -115,8 +117,10 @@ export function App() {
const bulkImport = useBulkImport(); const bulkImport = useBulkImport();
const sidePanel = useSidePanelState(); const sidePanel = useSidePanelState();
const { preference: themePreference, cycleTheme } = useTheme();
const [rollSkippedCount, setRollSkippedCount] = useState(0); const [rollSkippedCount, setRollSkippedCount] = useState(0);
const [rollSingleSkipped, setRollSingleSkipped] = useState(false);
const selectedCreature: Creature | null = sidePanel.selectedCreatureId const selectedCreature: Creature | null = sidePanel.selectedCreatureId
? (getCreature(sidePanel.selectedCreatureId) ?? null) ? (getCreature(sidePanel.selectedCreatureId) ?? null)
@@ -128,9 +132,12 @@ export function App() {
const handleAddFromBestiary = useCallback( const handleAddFromBestiary = useCallback(
(result: SearchResult) => { (result: SearchResult) => {
addFromBestiary(result); const creatureId = addFromBestiary(result);
if (creatureId && sidePanel.panelView.mode === "closed") {
sidePanel.showCreature(creatureId);
}
}, },
[addFromBestiary], [addFromBestiary, sidePanel.panelView.mode, sidePanel.showCreature],
); );
const handleCombatantStatBlock = useCallback( const handleCombatantStatBlock = useCallback(
@@ -141,18 +148,41 @@ export function App() {
); );
const handleRollInitiative = useCallback( const handleRollInitiative = useCallback(
(id: CombatantId) => { (id: CombatantId, mode: RollMode = "normal") => {
rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature); const diceRolls: [number, ...number[]] =
mode === "normal" ? [rollDice()] : [rollDice(), rollDice()];
const result = rollInitiativeUseCase(
makeStore(),
id,
diceRolls,
getCreature,
mode,
);
if (isDomainError(result)) {
setRollSingleSkipped(true);
const combatant = encounter.combatants.find((c) => c.id === id);
if (combatant?.creatureId) {
sidePanel.showCreature(combatant.creatureId);
}
}
}, },
[makeStore, getCreature], [makeStore, getCreature, encounter.combatants, sidePanel.showCreature],
); );
const handleRollAllInitiative = useCallback(() => { const handleRollAllInitiative = useCallback(
const result = rollAllInitiativeUseCase(makeStore(), rollDice, getCreature); (mode: RollMode = "normal") => {
const result = rollAllInitiativeUseCase(
makeStore(),
rollDice,
getCreature,
mode,
);
if (!isDomainError(result) && result.skippedNoSource > 0) { if (!isDomainError(result) && result.skippedNoSource > 0) {
setRollSkippedCount(result.skippedNoSource); setRollSkippedCount(result.skippedNoSource);
} }
}, [makeStore, getCreature]); },
[makeStore, getCreature],
);
const handleViewStatBlock = useCallback( const handleViewStatBlock = useCallback(
(result: SearchResult) => { (result: SearchResult) => {
@@ -187,6 +217,15 @@ export function App() {
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null); const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
const actionBarAnim = useActionBarAnimation(encounter.combatants.length); const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
// Auto-update stat block panel when the active combatant changes
const activeCreatureId =
encounter.combatants[encounter.activeIndex]?.creatureId;
useEffect(() => {
if (activeCreatureId && sidePanel.panelView.mode === "creature") {
sidePanel.updateCreature(activeCreatureId);
}
}, [activeCreatureId, sidePanel.panelView.mode, sidePanel.updateCreature]);
// 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);
useEffect(() => { useEffect(() => {
@@ -238,6 +277,8 @@ export function App() {
showRollAllInitiative={hasCreatureCombatants} showRollAllInitiative={hasCreatureCombatants}
rollAllInitiativeDisabled={!canRollAllInitiative} rollAllInitiativeDisabled={!canRollAllInitiative}
onOpenSourceManager={sidePanel.showSourceManager} onOpenSourceManager={sidePanel.showSourceManager}
themePreference={themePreference}
onCycleTheme={cycleTheme}
autoFocus autoFocus
/> />
</div> </div>
@@ -266,6 +307,9 @@ export function App() {
? () => handleCombatantStatBlock(c.creatureId as string) ? () => handleCombatantStatBlock(c.creatureId as string)
: undefined : undefined
} }
isStatBlockOpen={
c.creatureId === sidePanel.selectedCreatureId
}
onRollInitiative={ onRollInitiative={
c.creatureId ? handleRollInitiative : undefined c.creatureId ? handleRollInitiative : undefined
} }
@@ -297,6 +341,8 @@ export function App() {
showRollAllInitiative={hasCreatureCombatants} showRollAllInitiative={hasCreatureCombatants}
rollAllInitiativeDisabled={!canRollAllInitiative} rollAllInitiativeDisabled={!canRollAllInitiative}
onOpenSourceManager={sidePanel.showSourceManager} onOpenSourceManager={sidePanel.showSourceManager}
themePreference={themePreference}
onCycleTheme={cycleTheme}
/> />
</div> </div>
</> </>
@@ -360,6 +406,14 @@ export function App() {
/> />
)} )}
{!!rollSingleSkipped && (
<Toast
message="Can't roll — bestiary source not loaded"
onDismiss={() => setRollSingleSkipped(false)}
autoDismissMs={4000}
/>
)}
<PlayerCharacterSection <PlayerCharacterSection
ref={playerCharacterRef} ref={playerCharacterRef}
characters={playerCharacters} characters={playerCharacters}

View File

@@ -75,7 +75,7 @@ describe("CombatantRow", () => {
it("active combatant gets active border styling", () => { it("active combatant gets active border styling", () => {
const { container } = renderRow({ isActive: true }); const { container } = renderRow({ isActive: true });
const row = container.firstElementChild; const row = container.firstElementChild;
expect(row?.className).toContain("border-accent/40"); expect(row?.className).toContain("border-active-row-border");
}); });
it("unconscious combatant (currentHp === 0) gets dimmed styling", () => { it("unconscious combatant (currentHp === 0) gets dimmed styling", () => {

View File

@@ -1,4 +1,4 @@
import type { PlayerCharacter } from "@initiative/domain"; import type { PlayerCharacter, RollMode } from "@initiative/domain";
import { import {
Check, Check,
Eye, Eye,
@@ -6,14 +6,24 @@ import {
Import, Import,
Library, Library,
Minus, Minus,
Monitor,
Moon,
Plus, Plus,
Sun,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import React, { type RefObject, useDeferredValue, useState } from "react"; import React, {
type RefObject,
useCallback,
useDeferredValue,
useState,
} from "react";
import type { SearchResult } from "../hooks/use-bestiary.js"; import type { SearchResult } from "../hooks/use-bestiary.js";
import { useLongPress } from "../hooks/use-long-press.js";
import { cn } from "../lib/utils.js"; import { cn } from "../lib/utils.js";
import { D20Icon } from "./d20-icon.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 { RollModeMenu } from "./roll-mode-menu.js";
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"; import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
@@ -38,11 +48,13 @@ interface ActionBarProps {
playerCharacters?: readonly PlayerCharacter[]; playerCharacters?: readonly PlayerCharacter[];
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void; onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
onManagePlayers?: () => void; onManagePlayers?: () => void;
onRollAllInitiative?: () => void; onRollAllInitiative?: (mode?: RollMode) => void;
showRollAllInitiative?: boolean; showRollAllInitiative?: boolean;
rollAllInitiativeDisabled?: boolean; rollAllInitiativeDisabled?: boolean;
onOpenSourceManager?: () => void; onOpenSourceManager?: () => void;
autoFocus?: boolean; autoFocus?: boolean;
themePreference?: "system" | "light" | "dark";
onCycleTheme?: () => void;
} }
function creatureKey(r: SearchResult): string { function creatureKey(r: SearchResult): string {
@@ -171,7 +183,7 @@ function AddModeSuggestions({
> >
<Minus className="h-3 w-3" /> <Minus className="h-3 w-3" />
</button> </button>
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-foreground"> <span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground">
{queued.count} {queued.count}
</span> </span>
<button <button
@@ -215,12 +227,26 @@ function AddModeSuggestions({
); );
} }
const THEME_ICONS = {
system: Monitor,
light: Sun,
dark: Moon,
} as const;
const THEME_LABELS = {
system: "Theme: System",
light: "Theme: Light",
dark: "Theme: Dark",
} as const;
function buildOverflowItems(opts: { function buildOverflowItems(opts: {
onManagePlayers?: () => void; onManagePlayers?: () => void;
onOpenSourceManager?: () => void; onOpenSourceManager?: () => void;
bestiaryLoaded: boolean; bestiaryLoaded: boolean;
onBulkImport?: () => void; onBulkImport?: () => void;
bulkImportDisabled?: boolean; bulkImportDisabled?: boolean;
themePreference?: "system" | "light" | "dark";
onCycleTheme?: () => void;
}): OverflowMenuItem[] { }): OverflowMenuItem[] {
const items: OverflowMenuItem[] = []; const items: OverflowMenuItem[] = [];
if (opts.onManagePlayers) { if (opts.onManagePlayers) {
@@ -245,6 +271,16 @@ function buildOverflowItems(opts: {
disabled: opts.bulkImportDisabled, disabled: opts.bulkImportDisabled,
}); });
} }
if (opts.onCycleTheme) {
const pref = opts.themePreference ?? "system";
const ThemeIcon = THEME_ICONS[pref];
items.push({
icon: <ThemeIcon className="h-4 w-4" />,
label: THEME_LABELS[pref],
onClick: opts.onCycleTheme,
keepOpen: true,
});
}
return items; return items;
} }
@@ -265,6 +301,8 @@ export function ActionBar({
rollAllInitiativeDisabled, rollAllInitiativeDisabled,
onOpenSourceManager, onOpenSourceManager,
autoFocus, autoFocus,
themePreference,
onCycleTheme,
}: Readonly<ActionBarProps>) { }: Readonly<ActionBarProps>) {
const [nameInput, setNameInput] = useState(""); const [nameInput, setNameInput] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]); const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
@@ -448,12 +486,33 @@ export function ActionBar({
clearCustomFields(); clearCustomFields();
}; };
const [rollAllMenuPos, setRollAllMenuPos] = useState<{
x: number;
y: number;
} | null>(null);
const openRollAllMenu = useCallback((x: number, y: number) => {
setRollAllMenuPos({ x, y });
}, []);
const rollAllLongPress = useLongPress(
useCallback(
(e: React.TouchEvent) => {
const touch = e.touches[0];
if (touch) openRollAllMenu(touch.clientX, touch.clientY);
},
[openRollAllMenu],
),
);
const overflowItems = buildOverflowItems({ const overflowItems = buildOverflowItems({
onManagePlayers, onManagePlayers,
onOpenSourceManager, onOpenSourceManager,
bestiaryLoaded, bestiaryLoaded,
onBulkImport, onBulkImport,
bulkImportDisabled, bulkImportDisabled,
themePreference,
onCycleTheme,
}); });
return ( return (
@@ -574,18 +633,32 @@ export function ActionBar({
<Button type="submit">Add</Button> <Button type="submit">Add</Button>
)} )}
{showRollAllInitiative && !!onRollAllInitiative && ( {showRollAllInitiative && !!onRollAllInitiative && (
<>
<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-action"
onClick={onRollAllInitiative} onClick={() => onRollAllInitiative()}
onContextMenu={(e) => {
e.preventDefault();
openRollAllMenu(e.clientX, e.clientY);
}}
{...rollAllLongPress}
disabled={rollAllInitiativeDisabled} disabled={rollAllInitiativeDisabled}
title="Roll all initiative" title="Roll all initiative"
aria-label="Roll all initiative" aria-label="Roll all initiative"
> >
<D20Icon className="h-6 w-6" /> <D20Icon className="h-6 w-6" />
</Button> </Button>
{!!rollAllMenuPos && (
<RollModeMenu
position={rollAllMenuPos}
onSelect={(mode) => onRollAllInitiative(mode)}
onClose={() => setRollAllMenuPos(null)}
/>
)}
</>
)} )}
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />} {overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
</form> </form>

View File

@@ -3,9 +3,11 @@ import {
type ConditionId, type ConditionId,
deriveHpStatus, deriveHpStatus,
type PlayerIcon, type PlayerIcon,
type RollMode,
} from "@initiative/domain"; } from "@initiative/domain";
import { BookOpen, Brain, X } from "lucide-react"; import { Book, BookOpen, Brain, X } from "lucide-react";
import { type Ref, useCallback, useEffect, useRef, useState } from "react"; import { type Ref, useCallback, useEffect, useRef, useState } from "react";
import { useLongPress } from "../hooks/use-long-press";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { AcShield } from "./ac-shield"; import { AcShield } from "./ac-shield";
import { ConditionPicker } from "./condition-picker"; import { ConditionPicker } from "./condition-picker";
@@ -13,6 +15,7 @@ import { ConditionTags } from "./condition-tags";
import { D20Icon } from "./d20-icon"; import { D20Icon } from "./d20-icon";
import { HpAdjustPopover } from "./hp-adjust-popover"; import { HpAdjustPopover } from "./hp-adjust-popover";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map"; import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
import { RollModeMenu } from "./roll-mode-menu";
import { ConfirmButton } from "./ui/confirm-button"; import { ConfirmButton } from "./ui/confirm-button";
import { Input } from "./ui/input"; import { Input } from "./ui/input";
@@ -41,7 +44,8 @@ interface CombatantRowProps {
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void; onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
onToggleConcentration: (id: CombatantId) => void; onToggleConcentration: (id: CombatantId) => void;
onShowStatBlock?: () => void; onShowStatBlock?: () => void;
onRollInitiative?: (id: CombatantId) => void; isStatBlockOpen?: boolean;
onRollInitiative?: (id: CombatantId, mode?: RollMode) => void;
} }
function EditableName({ function EditableName({
@@ -278,11 +282,29 @@ function InitiativeDisplay({
combatantId: CombatantId; combatantId: CombatantId;
dimmed: boolean; dimmed: boolean;
onSetInitiative: (id: CombatantId, value: number | undefined) => void; onSetInitiative: (id: CombatantId, value: number | undefined) => void;
onRollInitiative?: (id: CombatantId) => void; onRollInitiative?: (id: CombatantId, mode?: RollMode) => void;
}>) { }>) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(initiative?.toString() ?? ""); const [draft, setDraft] = useState(initiative?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [menuPos, setMenuPos] = useState<{
x: number;
y: number;
} | null>(null);
const openMenu = useCallback((x: number, y: number) => {
setMenuPos({ x, y });
}, []);
const longPress = useLongPress(
useCallback(
(e: React.TouchEvent) => {
const touch = e.touches[0];
if (touch) openMenu(touch.clientX, touch.clientY);
},
[openMenu],
),
);
const commit = useCallback(() => { const commit = useCallback(() => {
if (draft === "") { if (draft === "") {
@@ -327,9 +349,15 @@ function InitiativeDisplay({
// Empty + bestiary creature → d20 roll button // Empty + bestiary creature → d20 roll button
if (initiative === undefined && onRollInitiative) { if (initiative === undefined && onRollInitiative) {
return ( return (
<>
<button <button
type="button" type="button"
onClick={() => onRollInitiative(combatantId)} onClick={() => onRollInitiative(combatantId)}
onContextMenu={(e) => {
e.preventDefault();
openMenu(e.clientX, e.clientY);
}}
{...longPress}
className={cn( className={cn(
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral", "flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
dimmed && "opacity-50", dimmed && "opacity-50",
@@ -339,6 +367,14 @@ function InitiativeDisplay({
> >
<D20Icon className="h-7 w-7" /> <D20Icon className="h-7 w-7" />
</button> </button>
{!!menuPos && (
<RollModeMenu
position={menuPos}
onSelect={(mode) => onRollInitiative(combatantId, mode)}
onClose={() => setMenuPos(null)}
/>
)}
</>
); );
} }
@@ -366,9 +402,9 @@ function rowBorderClass(
isConcentrating: boolean | undefined, isConcentrating: boolean | undefined,
): string { ): string {
if (isActive && isConcentrating) if (isActive && isConcentrating)
return "border border-l-2 border-accent/40 border-l-purple-400 bg-accent/10 card-glow"; return "border border-l-2 border-active-row-border border-l-purple-400 bg-active-row-bg card-glow";
if (isActive) if (isActive)
return "border border-l-2 border-accent/40 bg-accent/10 card-glow"; return "border border-l-2 border-active-row-border bg-active-row-bg card-glow";
if (isConcentrating) if (isConcentrating)
return "border border-l-2 border-transparent border-l-purple-400"; return "border border-l-2 border-transparent border-l-purple-400";
return "border border-l-2 border-transparent"; return "border border-l-2 border-transparent";
@@ -396,6 +432,7 @@ export function CombatantRow({
onToggleCondition, onToggleCondition,
onToggleConcentration, onToggleConcentration,
onShowStatBlock, onShowStatBlock,
isStatBlockOpen,
onRollInitiative, onRollInitiative,
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) { }: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
const { id, name, initiative, maxHp, currentHp } = combatant; const { id, name, initiative, maxHp, currentHp } = combatant;
@@ -480,9 +517,9 @@ export function CombatantRow({
onClick={onShowStatBlock} onClick={onShowStatBlock}
title="View stat block" title="View stat block"
aria-label="View stat block" aria-label="View stat block"
className="shrink-0 text-muted-foreground transition-colors hover:text-hover-neutral" className="shrink-0 text-foreground transition-colors hover:text-hover-neutral"
> >
<BookOpen size={14} /> {isStatBlockOpen ? <BookOpen size={16} /> : <Book size={16} />}
</button> </button>
)} )}
{!!combatant.icon && {!!combatant.icon &&
@@ -495,7 +532,7 @@ export function CombatantRow({
]; ];
return PcIcon ? ( return PcIcon ? (
<PcIcon <PcIcon
size={14} size={16}
style={{ color: iconColor }} style={{ color: iconColor }}
className="shrink-0" className="shrink-0"
/> />

View File

@@ -113,7 +113,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
<button <button
type="button" type="button"
disabled={!isValid} disabled={!isValid}
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" className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-red-400 transition-colors hover:bg-hp-damage-hover-bg 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"
@@ -123,7 +123,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
<button <button
type="button" type="button"
disabled={!isValid} disabled={!isValid}
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" className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-emerald-400 transition-colors hover:bg-hp-heal-hover-bg 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"

View File

@@ -0,0 +1,88 @@
import type { RollMode } from "@initiative/domain";
import { ChevronsDown, ChevronsUp } from "lucide-react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
interface RollModeMenuProps {
readonly position: { x: number; y: number };
readonly onSelect: (mode: RollMode) => void;
readonly onClose: () => void;
}
export function RollModeMenu({
position,
onSelect,
onClose,
}: RollModeMenuProps) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
const vh = document.documentElement.clientHeight;
let left = position.x;
let top = position.y;
if (left + rect.width > vw) left = vw - rect.width - 8;
if (left < 8) left = 8;
if (top + rect.height > vh) top = position.y - rect.height;
if (top < 8) top = 8;
setPos({ top, left });
}, [position.x, position.y]);
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [onClose]);
return (
<div
ref={ref}
className="card-glow fixed z-50 min-w-40 rounded-lg border border-border bg-card py-1"
style={
pos
? { top: pos.top, left: pos.left }
: { visibility: "hidden" as const }
}
>
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-emerald-400 text-sm hover:bg-hover-neutral-bg"
onClick={() => {
onSelect("advantage");
onClose();
}}
>
<ChevronsUp className="h-4 w-4" />
Advantage
</button>
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-red-400 text-sm hover:bg-hover-neutral-bg"
onClick={() => {
onSelect("disadvantage");
onClose();
}}
>
<ChevronsDown className="h-4 w-4" />
Disadvantage
</button>
</div>
);
}

View File

@@ -30,7 +30,7 @@ function PropertyLine({
function SectionDivider() { function SectionDivider() {
return ( return (
<div className="my-2 h-px bg-gradient-to-r from-amber-800/60 via-amber-700/40 to-transparent" /> <div className="my-2 h-px bg-gradient-to-r from-stat-divider-from via-stat-divider-via to-transparent" />
); );
} }
@@ -54,7 +54,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
<div className="space-y-1 text-foreground"> <div className="space-y-1 text-foreground">
{/* Header */} {/* Header */}
<div> <div>
<h2 className="font-bold text-amber-400 text-xl">{creature.name}</h2> <h2 className="font-bold text-stat-heading text-xl">{creature.name}</h2>
<p className="text-muted-foreground text-sm italic"> <p className="text-muted-foreground text-sm italic">
{creature.size} {creature.type}, {creature.alignment} {creature.size} {creature.type}, {creature.alignment}
</p> </p>
@@ -194,7 +194,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
{creature.actions && creature.actions.length > 0 && ( {creature.actions && creature.actions.length > 0 && (
<> <>
<SectionDivider /> <SectionDivider />
<h3 className="font-bold text-amber-400 text-base">Actions</h3> <h3 className="font-bold text-base text-stat-heading">Actions</h3>
<div className="space-y-2"> <div className="space-y-2">
{creature.actions.map((a) => ( {creature.actions.map((a) => (
<div key={a.name} className="text-sm"> <div key={a.name} className="text-sm">
@@ -209,7 +209,9 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
{creature.bonusActions && creature.bonusActions.length > 0 && ( {creature.bonusActions && creature.bonusActions.length > 0 && (
<> <>
<SectionDivider /> <SectionDivider />
<h3 className="font-bold text-amber-400 text-base">Bonus Actions</h3> <h3 className="font-bold text-base text-stat-heading">
Bonus Actions
</h3>
<div className="space-y-2"> <div className="space-y-2">
{creature.bonusActions.map((a) => ( {creature.bonusActions.map((a) => (
<div key={a.name} className="text-sm"> <div key={a.name} className="text-sm">
@@ -224,7 +226,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
{creature.reactions && creature.reactions.length > 0 && ( {creature.reactions && creature.reactions.length > 0 && (
<> <>
<SectionDivider /> <SectionDivider />
<h3 className="font-bold text-amber-400 text-base">Reactions</h3> <h3 className="font-bold text-base text-stat-heading">Reactions</h3>
<div className="space-y-2"> <div className="space-y-2">
{creature.reactions.map((a) => ( {creature.reactions.map((a) => (
<div key={a.name} className="text-sm"> <div key={a.name} className="text-sm">
@@ -239,7 +241,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
{!!creature.legendaryActions && ( {!!creature.legendaryActions && (
<> <>
<SectionDivider /> <SectionDivider />
<h3 className="font-bold text-amber-400 text-base"> <h3 className="font-bold text-base text-stat-heading">
Legendary Actions Legendary Actions
</h3> </h3>
<p className="text-muted-foreground text-sm italic"> <p className="text-muted-foreground text-sm italic">

View File

@@ -23,7 +23,7 @@ export function TurnNavigation({
return ( return (
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3"> <div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
<Button <Button
variant="outline" variant="ghost"
size="icon" size="icon"
onClick={onRetreatTurn} onClick={onRetreatTurn}
disabled={!hasCombatants || isAtStart} disabled={!hasCombatants || isAtStart}
@@ -53,7 +53,7 @@ export function TurnNavigation({
className="text-muted-foreground" className="text-muted-foreground"
/> />
<Button <Button
variant="outline" variant="ghost"
size="icon" size="icon"
onClick={onAdvanceTurn} onClick={onAdvanceTurn}
disabled={!hasCombatants} disabled={!hasCombatants}

View File

@@ -7,6 +7,7 @@ export interface OverflowMenuItem {
readonly label: string; readonly label: string;
readonly onClick: () => void; readonly onClick: () => void;
readonly disabled?: boolean; readonly disabled?: boolean;
readonly keepOpen?: boolean;
} }
interface OverflowMenuProps { interface OverflowMenuProps {
@@ -58,7 +59,7 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
disabled={item.disabled} disabled={item.disabled}
onClick={() => { onClick={() => {
item.onClick(); item.onClick();
setOpen(false); if (!item.keepOpen) setOpen(false);
}} }}
> >
{item.icon} {item.icon}

View File

@@ -137,7 +137,9 @@ describe("useEncounter", () => {
type: "humanoid", type: "humanoid",
}; };
act(() => result.current.addFromBestiary(entry)); act(() => {
result.current.addFromBestiary(entry);
});
expect(result.current.hasCreatureCombatants).toBe(true); expect(result.current.hasCreatureCombatants).toBe(true);
expect(result.current.canRollAllInitiative).toBe(true); expect(result.current.canRollAllInitiative).toBe(true);
@@ -158,7 +160,9 @@ describe("useEncounter", () => {
type: "humanoid", type: "humanoid",
}; };
act(() => result.current.addFromBestiary(entry)); act(() => {
result.current.addFromBestiary(entry);
});
const combatant = result.current.encounter.combatants[0]; const combatant = result.current.encounter.combatants[0];
expect(combatant.name).toBe("Goblin"); expect(combatant.name).toBe("Goblin");
@@ -183,8 +187,12 @@ describe("useEncounter", () => {
type: "humanoid", type: "humanoid",
}; };
act(() => result.current.addFromBestiary(entry)); act(() => {
act(() => result.current.addFromBestiary(entry)); result.current.addFromBestiary(entry);
});
act(() => {
result.current.addFromBestiary(entry);
});
const names = result.current.encounter.combatants.map((c) => c.name); const names = result.current.encounter.combatants.map((c) => c.name);
expect(names).toContain("Goblin 1"); expect(names).toContain("Goblin 1");

View File

@@ -17,6 +17,7 @@ import type {
BestiaryIndexEntry, BestiaryIndexEntry,
CombatantId, CombatantId,
ConditionId, ConditionId,
CreatureId,
DomainEvent, DomainEvent,
Encounter, Encounter,
PlayerCharacter, PlayerCharacter,
@@ -265,7 +266,7 @@ export function useEncounter() {
}, [makeStore]); }, [makeStore]);
const addFromBestiary = useCallback( const addFromBestiary = useCallback(
(entry: BestiaryIndexEntry) => { (entry: BestiaryIndexEntry): CreatureId | null => {
const store = makeStore(); const store = makeStore();
const existingNames = store.get().combatants.map((c) => c.name); const existingNames = store.get().combatants.map((c) => c.name);
const { newName, renames } = resolveCreatureName( const { newName, renames } = resolveCreatureName(
@@ -284,7 +285,7 @@ export function useEncounter() {
// Add combatant with resolved name // Add combatant with resolved name
const id = combatantId(`c-${++nextId.current}`); const id = combatantId(`c-${++nextId.current}`);
const addResult = addCombatantUseCase(makeStore(), id, newName); const addResult = addCombatantUseCase(makeStore(), id, newName);
if (isDomainError(addResult)) return; if (isDomainError(addResult)) return null;
// Set HP // Set HP
const hpResult = setHpUseCase(makeStore(), id, entry.hp); const hpResult = setHpUseCase(makeStore(), id, entry.hp);
@@ -317,6 +318,8 @@ export function useEncounter() {
}); });
setEvents((prev) => [...prev, ...addResult]); setEvents((prev) => [...prev, ...addResult]);
return cId;
}, },
[makeStore], [makeStore],
); );

View File

@@ -0,0 +1,32 @@
import { useCallback, useRef } from "react";
const LONG_PRESS_MS = 500;
export function useLongPress(onLongPress: (e: React.TouchEvent) => void) {
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const firedRef = useRef(false);
const onTouchStart = useCallback(
(e: React.TouchEvent) => {
firedRef.current = false;
timerRef.current = setTimeout(() => {
firedRef.current = true;
onLongPress(e);
}, LONG_PRESS_MS);
},
[onLongPress],
);
const onTouchEnd = useCallback((e: React.TouchEvent) => {
clearTimeout(timerRef.current);
if (firedRef.current) {
e.preventDefault();
}
}, []);
const onTouchMove = useCallback(() => {
clearTimeout(timerRef.current);
}, []);
return { onTouchStart, onTouchEnd, onTouchMove };
}

View File

@@ -19,6 +19,7 @@ interface SidePanelState {
interface SidePanelActions { interface SidePanelActions {
showCreature: (creatureId: CreatureId) => void; showCreature: (creatureId: CreatureId) => void;
updateCreature: (creatureId: CreatureId) => void;
showBulkImport: () => void; showBulkImport: () => void;
showSourceManager: () => void; showSourceManager: () => void;
dismissPanel: () => void; dismissPanel: () => void;
@@ -52,6 +53,10 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
setIsRightPanelCollapsed(false); setIsRightPanelCollapsed(false);
}, []); }, []);
const updateCreature = useCallback((creatureId: CreatureId) => {
setPanelView({ mode: "creature", creatureId });
}, []);
const showBulkImport = useCallback(() => { const showBulkImport = useCallback(() => {
setPanelView({ mode: "bulk-import" }); setPanelView({ mode: "bulk-import" });
setIsRightPanelCollapsed(false); setIsRightPanelCollapsed(false);
@@ -91,6 +96,7 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
pinnedCreatureId, pinnedCreatureId,
isWideDesktop, isWideDesktop,
showCreature, showCreature,
updateCreature,
showBulkImport, showBulkImport,
showSourceManager, showSourceManager,
dismissPanel, dismissPanel,

View File

@@ -0,0 +1,98 @@
import { useCallback, useEffect, useSyncExternalStore } from "react";
type ThemePreference = "system" | "light" | "dark";
type ResolvedTheme = "light" | "dark";
const STORAGE_KEY = "initiative:theme";
const listeners = new Set<() => void>();
let currentPreference: ThemePreference = loadPreference();
function loadPreference(): ThemePreference {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === "light" || raw === "dark" || raw === "system") return raw;
} catch {
// storage unavailable
}
return "system";
}
function savePreference(pref: ThemePreference): void {
try {
localStorage.setItem(STORAGE_KEY, pref);
} catch {
// quota exceeded or storage unavailable
}
}
function getSystemTheme(): ResolvedTheme {
if (typeof globalThis.matchMedia !== "function") return "dark";
return globalThis.matchMedia("(prefers-color-scheme: light)").matches
? "light"
: "dark";
}
function resolve(pref: ThemePreference): ResolvedTheme {
return pref === "system" ? getSystemTheme() : pref;
}
function applyTheme(resolved: ResolvedTheme): void {
document.documentElement.dataset.theme = resolved;
}
function notifyAll(): void {
for (const listener of listeners) {
listener();
}
}
// Apply on load
applyTheme(resolve(currentPreference));
// Listen for OS preference changes
if (typeof globalThis.matchMedia === "function") {
globalThis
.matchMedia("(prefers-color-scheme: light)")
.addEventListener("change", () => {
if (currentPreference === "system") {
applyTheme(resolve("system"));
notifyAll();
}
});
}
function subscribe(callback: () => void): () => void {
listeners.add(callback);
return () => listeners.delete(callback);
}
function getSnapshot(): ThemePreference {
return currentPreference;
}
const CYCLE: ThemePreference[] = ["system", "light", "dark"];
export function useTheme() {
const preference = useSyncExternalStore(subscribe, getSnapshot);
const resolved = resolve(preference);
useEffect(() => {
applyTheme(resolved);
}, [resolved]);
const setPreference = useCallback((pref: ThemePreference) => {
currentPreference = pref;
savePreference(pref);
applyTheme(resolve(pref));
notifyAll();
}, []);
const cycleTheme = useCallback(() => {
const idx = CYCLE.indexOf(currentPreference);
const next = CYCLE[(idx + 1) % CYCLE.length];
setPreference(next);
}, [setPreference]);
return { preference, resolved, setPreference, cycleTheme } as const;
}

View File

@@ -19,12 +19,47 @@
--color-hover-neutral-bg: oklch(0.623 0.214 259 / 0.15); --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;
--color-stat-heading: #fbbf24;
--color-stat-divider-from: oklch(0.5 0.1 65 / 0.6);
--color-stat-divider-via: oklch(0.5 0.1 65 / 0.4);
--color-hp-damage-hover-bg: oklch(0.25 0.05 25);
--color-hp-heal-hover-bg: oklch(0.25 0.05 155);
--color-active-row-bg: oklch(0.623 0.214 259 / 0.1);
--color-active-row-border: oklch(0.623 0.214 259 / 0.4);
--radius-sm: 0.25rem; --radius-sm: 0.25rem;
--radius-md: 0.5rem; --radius-md: 0.5rem;
--radius-lg: 0.75rem; --radius-lg: 0.75rem;
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif; --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
} }
[data-theme="light"] {
--color-background: #eeecea;
--color-foreground: #374151;
--color-muted: #e0ddd9;
--color-muted-foreground: #6b7280;
--color-card: #f7f6f4;
--color-card-foreground: #374151;
--color-border: #ddd9d5;
--color-input: #cdc8c3;
--color-primary: #2563eb;
--color-primary-foreground: #ffffff;
--color-accent: #2563eb;
--color-destructive: #dc2626;
--color-hover-neutral: var(--color-primary);
--color-hover-action: var(--color-primary);
--color-hover-destructive: var(--color-destructive);
--color-hover-neutral-bg: oklch(0.623 0.214 259 / 0.08);
--color-hover-action-bg: var(--color-muted);
--color-hover-destructive-bg: transparent;
--color-stat-heading: #92400e;
--color-stat-divider-from: oklch(0.55 0.1 65 / 0.5);
--color-stat-divider-via: oklch(0.55 0.1 65 / 0.25);
--color-hp-damage-hover-bg: #fef2f2;
--color-hp-heal-hover-bg: #ecfdf5;
--color-active-row-bg: oklch(0.623 0.214 259 / 0.08);
--color-active-row-border: oklch(0.623 0.214 259 / 0.25);
}
@keyframes concentration-shake { @keyframes concentration-shake {
0% { 0% {
translate: 0; translate: 0;
@@ -178,6 +213,11 @@
box-shadow: box-shadow:
0 0 15px -2px oklch(0.623 0.214 259 / 0.2), 0 0 15px -2px oklch(0.623 0.214 259 / 0.2),
inset 0 1px 0 0 oklch(0.7 0.15 259 / 0.1); inset 0 1px 0 0 oklch(0.7 0.15 259 / 0.1);
[data-theme="light"] & {
background-image: none;
box-shadow: 0 1px 3px 0 oklch(0 0 0 / 0.08);
}
} }
@utility panel-glow { @utility panel-glow {
@@ -189,6 +229,11 @@
box-shadow: box-shadow:
0 0 20px -2px oklch(0.623 0.214 259 / 0.15), 0 0 20px -2px oklch(0.623 0.214 259 / 0.15),
inset 0 1px 0 0 oklch(0.7 0.15 259 / 0.1); inset 0 1px 0 0 oklch(0.7 0.15 259 / 0.1);
[data-theme="light"] & {
background-image: none;
box-shadow: -1px 0 6px 0 oklch(0 0 0 / 0.1);
}
} }
* { * {
@@ -207,3 +252,7 @@ body {
color: var(--color-foreground); color: var(--color-foreground);
font-family: var(--font-sans); font-family: var(--font-sans);
} }
[data-theme="light"] body {
background-image: none;
}

View File

@@ -161,6 +161,48 @@ describe("rollAllInitiativeUseCase", () => {
expect(store.saved).toBeNull(); expect(store.saved).toBeNull();
}); });
it("uses higher roll with advantage", () => {
const enc = encounterWithCombatants([
{ name: "A", creatureId: "creature-a" },
]);
const store = stubEncounterStore(enc);
const creature = makeCreature("creature-a");
// Alternating rolls: 5, 15 → advantage picks 15
// Dex 14 → modifier +2, so 15 + 2 = 17
let call = 0;
const result = rollAllInitiativeUseCase(
store,
() => (++call % 2 === 1 ? 5 : 15),
(id) => (id === CREATURE_A ? creature : undefined),
"advantage",
);
expectSuccess(result);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(17);
});
it("uses lower roll with disadvantage", () => {
const enc = encounterWithCombatants([
{ name: "A", creatureId: "creature-a" },
]);
const store = stubEncounterStore(enc);
const creature = makeCreature("creature-a");
// Alternating rolls: 15, 5 → disadvantage picks 5
// Dex 14 → modifier +2, so 5 + 2 = 7
let call = 0;
const result = rollAllInitiativeUseCase(
store,
() => (++call % 2 === 1 ? 15 : 5),
(id) => (id === CREATURE_A ? creature : undefined),
"disadvantage",
);
expectSuccess(result);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(7);
});
it("saves encounter once at the end", () => { it("saves encounter once at the end", () => {
const enc = encounterWithCombatants([ const enc = encounterWithCombatants([
{ name: "A", creatureId: "creature-a" }, { name: "A", creatureId: "creature-a" },

View File

@@ -61,7 +61,7 @@ describe("rollInitiativeUseCase", () => {
const result = rollInitiativeUseCase( const result = rollInitiativeUseCase(
store, store,
combatantId("unknown"), combatantId("unknown"),
10, [10],
() => undefined, () => undefined,
); );
@@ -80,7 +80,7 @@ describe("rollInitiativeUseCase", () => {
const result = rollInitiativeUseCase( const result = rollInitiativeUseCase(
store, store,
combatantId("Fighter"), combatantId("Fighter"),
10, [10],
() => undefined, () => undefined,
); );
@@ -96,7 +96,7 @@ describe("rollInitiativeUseCase", () => {
const result = rollInitiativeUseCase( const result = rollInitiativeUseCase(
store, store,
combatantId("Goblin"), combatantId("Goblin"),
10, [10],
() => undefined, () => undefined,
); );
@@ -116,7 +116,7 @@ describe("rollInitiativeUseCase", () => {
const result = rollInitiativeUseCase( const result = rollInitiativeUseCase(
store, store,
combatantId("Goblin"), combatantId("Goblin"),
10, [10],
(id) => (id === GOBLIN_ID ? creature : undefined), (id) => (id === GOBLIN_ID ? creature : undefined),
); );
@@ -124,6 +124,42 @@ describe("rollInitiativeUseCase", () => {
expect(requireSaved(store.saved).combatants[0].initiative).toBe(12); expect(requireSaved(store.saved).combatants[0].initiative).toBe(12);
}); });
it("uses higher roll with advantage", () => {
const creature = makeCreature();
const enc = encounterWithCreatureLink("Goblin", GOBLIN_ID);
const store = stubEncounterStore(enc);
// Dex 14 -> modifier +2, advantage picks max(5, 15) = 15, 15 + 2 = 17
const result = rollInitiativeUseCase(
store,
combatantId("Goblin"),
[5, 15],
(id) => (id === GOBLIN_ID ? creature : undefined),
"advantage",
);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(17);
});
it("uses lower roll with disadvantage", () => {
const creature = makeCreature();
const enc = encounterWithCreatureLink("Goblin", GOBLIN_ID);
const store = stubEncounterStore(enc);
// Dex 14 -> modifier +2, disadvantage picks min(5, 15) = 5, 5 + 2 = 7
const result = rollInitiativeUseCase(
store,
combatantId("Goblin"),
[5, 15],
(id) => (id === GOBLIN_ID ? creature : undefined),
"disadvantage",
);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(7);
});
it("applies initiative proficiency bonus correctly", () => { it("applies initiative proficiency bonus correctly", () => {
// CR 5 -> PB 3, dex 16 -> mod +3, initiativeProficiency 1 // CR 5 -> PB 3, dex 16 -> mod +3, initiativeProficiency 1
// modifier = 3 + 1*3 = 6, roll 8 + 6 = 14 // modifier = 3 + 1*3 = 6, roll 8 + 6 = 14
@@ -145,7 +181,7 @@ describe("rollInitiativeUseCase", () => {
const result = rollInitiativeUseCase( const result = rollInitiativeUseCase(
store, store,
combatantId("Monster"), combatantId("Monster"),
8, [8],
(id) => (id === GOBLIN_ID ? creature : undefined), (id) => (id === GOBLIN_ID ? creature : undefined),
); );

View File

@@ -5,7 +5,9 @@ import {
type DomainError, type DomainError,
type DomainEvent, type DomainEvent,
isDomainError, isDomainError,
type RollMode,
rollInitiative, rollInitiative,
selectRoll,
setInitiative, setInitiative,
} from "@initiative/domain"; } from "@initiative/domain";
import type { EncounterStore } from "./ports.js"; import type { EncounterStore } from "./ports.js";
@@ -19,6 +21,7 @@ export function rollAllInitiativeUseCase(
store: EncounterStore, store: EncounterStore,
rollDice: () => number, rollDice: () => number,
getCreature: (id: CreatureId) => Creature | undefined, getCreature: (id: CreatureId) => Creature | undefined,
mode: RollMode = "normal",
): RollAllResult | DomainError { ): RollAllResult | DomainError {
let encounter = store.get(); let encounter = store.get();
const allEvents: DomainEvent[] = []; const allEvents: DomainEvent[] = [];
@@ -39,7 +42,10 @@ export function rollAllInitiativeUseCase(
cr: creature.cr, cr: creature.cr,
initiativeProficiency: creature.initiativeProficiency, initiativeProficiency: creature.initiativeProficiency,
}); });
const value = rollInitiative(rollDice(), modifier); const roll1 = rollDice();
const effectiveRoll =
mode === "normal" ? roll1 : selectRoll(roll1, rollDice(), mode);
const value = rollInitiative(effectiveRoll, modifier);
if (isDomainError(value)) { if (isDomainError(value)) {
return value; return value;

View File

@@ -6,7 +6,9 @@ import {
type DomainError, type DomainError,
type DomainEvent, type DomainEvent,
isDomainError, isDomainError,
type RollMode,
rollInitiative, rollInitiative,
selectRoll,
setInitiative, setInitiative,
} from "@initiative/domain"; } from "@initiative/domain";
import type { EncounterStore } from "./ports.js"; import type { EncounterStore } from "./ports.js";
@@ -14,8 +16,9 @@ import type { EncounterStore } from "./ports.js";
export function rollInitiativeUseCase( export function rollInitiativeUseCase(
store: EncounterStore, store: EncounterStore,
combatantId: CombatantId, combatantId: CombatantId,
diceRoll: number, diceRolls: readonly [number, ...number[]],
getCreature: (id: CreatureId) => Creature | undefined, getCreature: (id: CreatureId) => Creature | undefined,
mode: RollMode = "normal",
): DomainEvent[] | DomainError { ): DomainEvent[] | DomainError {
const encounter = store.get(); const encounter = store.get();
const combatant = encounter.combatants.find((c) => c.id === combatantId); const combatant = encounter.combatants.find((c) => c.id === combatantId);
@@ -50,7 +53,11 @@ export function rollInitiativeUseCase(
cr: creature.cr, cr: creature.cr,
initiativeProficiency: creature.initiativeProficiency, initiativeProficiency: creature.initiativeProficiency,
}); });
const value = rollInitiative(diceRoll, modifier); const effectiveRoll =
mode === "normal"
? diceRolls[0]
: selectRoll(diceRolls[0], diceRolls[1] ?? diceRolls[0], mode);
const value = rollInitiative(effectiveRoll, modifier);
if (isDomainError(value)) { if (isDomainError(value)) {
return value; return value;

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { rollInitiative } from "../roll-initiative.js"; import { rollInitiative, selectRoll } from "../roll-initiative.js";
import { isDomainError } from "../types.js"; import { isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js"; import { expectDomainError } from "./test-helpers.js";
@@ -63,3 +63,31 @@ describe("rollInitiative", () => {
}); });
}); });
}); });
describe("selectRoll", () => {
it("normal mode returns the first roll", () => {
expect(selectRoll(8, 15, "normal")).toBe(8);
});
it("advantage returns the higher roll", () => {
expect(selectRoll(8, 15, "advantage")).toBe(15);
});
it("advantage returns the higher roll (reversed)", () => {
expect(selectRoll(15, 8, "advantage")).toBe(15);
});
it("disadvantage returns the lower roll", () => {
expect(selectRoll(8, 15, "disadvantage")).toBe(8);
});
it("disadvantage returns the lower roll (reversed)", () => {
expect(selectRoll(15, 8, "disadvantage")).toBe(8);
});
it("equal rolls return the same value for all modes", () => {
expect(selectRoll(12, 12, "normal")).toBe(12);
expect(selectRoll(12, 12, "advantage")).toBe(12);
expect(selectRoll(12, 12, "disadvantage")).toBe(12);
});
});

View File

@@ -84,7 +84,11 @@ export {
removeCombatant, removeCombatant,
} from "./remove-combatant.js"; } from "./remove-combatant.js";
export { retreatTurn } from "./retreat-turn.js"; export { retreatTurn } from "./retreat-turn.js";
export { rollInitiative } from "./roll-initiative.js"; export {
type RollMode,
rollInitiative,
selectRoll,
} from "./roll-initiative.js";
export { type SetAcSuccess, setAc } from "./set-ac.js"; export { type SetAcSuccess, setAc } from "./set-ac.js";
export { type SetHpSuccess, setHp } from "./set-hp.js"; export { type SetHpSuccess, setHp } from "./set-hp.js";
export { export {

View File

@@ -1,5 +1,21 @@
import type { DomainError } from "./types.js"; import type { DomainError } from "./types.js";
export type RollMode = "normal" | "advantage" | "disadvantage";
/**
* Selects the effective roll from two dice values based on the roll mode.
* Advantage takes the higher, disadvantage takes the lower.
*/
export function selectRoll(
roll1: number,
roll2: number,
mode: RollMode,
): number {
if (mode === "advantage") return Math.max(roll1, roll2);
if (mode === "disadvantage") return Math.min(roll1, roll2);
return roll1;
}
/** /**
* Pure function that computes initiative from a resolved dice roll and modifier. * Pure function that computes initiative from a resolved dice roll and modifier.
* The dice roll must be an integer in [1, 20]. * The dice roll must be an integer in [1, 20].

View File

@@ -264,7 +264,7 @@ As a DM with a creature pinned, I want to collapse the right (browse) panel inde
### Edge Cases ### Edge Cases
- User pins a creature and then that creature is removed from the encounter: the pinned panel continues displaying the creature's stat block (data is already loaded). - User pins a creature and then that creature is removed from the encounter: the pinned panel continues displaying the creature's stat block (data is already loaded).
- Active combatant changes while panel is open or collapsed: advancing turns does not auto-show or update the stat block panel. The panel only changes when the user explicitly clicks a book icon. If the panel is collapsed, it stays collapsed. - Active combatant changes while panel is open: if the new active combatant has a creature, the panel auto-updates to show that creature's stat block. If the new active combatant has no creature, the panel remains on the previous creature. If the panel is collapsed, it stays collapsed. If the panel is closed, it stays closed.
- Viewport resized from wide to narrow while a creature is pinned: the pinned (left) panel is hidden and the pin button disappears; resizing back to wide restores the pinned panel. - Viewport resized from wide to narrow while a creature is pinned: the pinned (left) panel is hidden and the pin button disappears; resizing back to wide restores the pinned panel.
- User is in bulk import mode and tries to collapse: the collapse/expand behavior applies to the bulk import panel identically. - User is in bulk import mode and tries to collapse: the collapse/expand behavior applies to the bulk import panel identically.
- Panel showing a source fetch prompt: the pin button is hidden. - Panel showing a source fetch prompt: the pin button is hidden.