Compare commits
16 Commits
0.9.4
...
fab9301b20
| Author | SHA1 | Date | |
|---|---|---|---|
| fab9301b20 | |||
| d653cfe489 | |||
| 228a2603e8 | |||
| 27ff8ba1ad | |||
| 4cfcefe6c3 | |||
| 8baccf3cd3 | |||
| a9ca31e9bc | |||
| 64a1f0b8db | |||
| 5e5812bcaa | |||
| 9e09c8ae2a | |||
| 4d0ec0c7b2 | |||
| fe62f2eb2f | |||
| 7092677273 | |||
| e1a06c9d59 | |||
| 4043612ccf | |||
| cfd4aef724 |
+10
-1
@@ -2,7 +2,16 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0e1a2e" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta property="og:title" content="Initiative Tracker" />
|
||||
<meta property="og:description" content="D&D combat initiative tracker" />
|
||||
<meta property="og:image" content="https://initiative.dostulata.rocks/icon-512.png" />
|
||||
<meta property="og:url" content="https://initiative.dostulata.rocks/" />
|
||||
<meta property="og:type" content="website" />
|
||||
<title>Initiative Tracker</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<defs>
|
||||
<linearGradient id="f" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#60a5fa"/>
|
||||
<stop offset="100%" stop-color="#2563eb"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g transform="translate(16 15) scale(1.55)" fill="none" stroke="url(#f)" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76" fill="url(#f)" fill-opacity="0.15"/>
|
||||
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49" fill="url(#f)" fill-opacity="0.25"/>
|
||||
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44" fill="url(#f)" fill-opacity="0.2"/>
|
||||
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44" fill="url(#f)" fill-opacity="0.1"/>
|
||||
<line x1="-4.75" y1="-2.74" x2="-8.19" y2="-4.74"/>
|
||||
<line x1="8.18" y1="-4.74" x2="4.75" y2="-2.74"/>
|
||||
<line x1="-0.01" y1="5.44" x2="-0.01" y2="9.51"/>
|
||||
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76"/>
|
||||
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49"/>
|
||||
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44"/>
|
||||
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<radialGradient id="bg" cx="50%" cy="40%" r="70%">
|
||||
<stop offset="0%" stop-color="#1a2e4a"/>
|
||||
<stop offset="100%" stop-color="#0e1a2e"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="d20fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#60a5fa"/>
|
||||
<stop offset="100%" stop-color="#2563eb"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="d20stroke" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#93c5fd"/>
|
||||
<stop offset="100%" stop-color="#3b82f6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
|
||||
<g transform="translate(256 256) scale(8.5)" fill="none" stroke="url(#d20stroke)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76" fill="url(#d20fill)" fill-opacity="0.15"/>
|
||||
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49" fill="url(#d20fill)" fill-opacity="0.25"/>
|
||||
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44" fill="url(#d20fill)" fill-opacity="0.2"/>
|
||||
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44" fill="url(#d20fill)" fill-opacity="0.1"/>
|
||||
<line x1="-4.75" y1="-2.74" x2="-8.19" y2="-4.74"/>
|
||||
<line x1="8.18" y1="-4.74" x2="4.75" y2="-2.74"/>
|
||||
<line x1="-0.01" y1="5.44" x2="-0.01" y2="9.51"/>
|
||||
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76"/>
|
||||
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49"/>
|
||||
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44"/>
|
||||
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44"/>
|
||||
</g>
|
||||
<text x="256" y="278" text-anchor="middle" dominant-baseline="central" font-family="Inter, system-ui, sans-serif" font-weight="700" font-size="52" fill="#93c5fd" letter-spacing="1">20</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Initiative Tracker",
|
||||
"short_name": "Initiative",
|
||||
"description": "D&D combat initiative tracker",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0e1a2e",
|
||||
"theme_color": "#0e1a2e",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
+18
-4
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ActionBar } from "./components/action-bar.js";
|
||||
import { BulkImportToasts } from "./components/bulk-import-toasts.js";
|
||||
import { CombatantRow } from "./components/combatant-row.js";
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
PlayerCharacterSection,
|
||||
type PlayerCharacterSectionHandle,
|
||||
} from "./components/player-character-section.js";
|
||||
import { SettingsModal } from "./components/settings-modal.js";
|
||||
import { StatBlockPanel } from "./components/stat-block-panel.js";
|
||||
import { Toast } from "./components/toast.js";
|
||||
import { TurnNavigation } from "./components/turn-navigation.js";
|
||||
@@ -23,6 +24,7 @@ export function App() {
|
||||
|
||||
useAutoStatBlock();
|
||||
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
|
||||
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||
@@ -41,10 +43,13 @@ export function App() {
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh flex-col">
|
||||
<div className="relative mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-3 px-4">
|
||||
<div className="relative mx-auto flex min-h-0 w-full flex-1 flex-col gap-3 sm:max-w-2xl sm:px-4">
|
||||
{!!actionBarAnim.showTopBar && (
|
||||
<div
|
||||
className={cn("shrink-0 pt-8", actionBarAnim.topBarClass)}
|
||||
className={cn(
|
||||
"shrink-0 pt-[env(safe-area-inset-top)] sm:pt-[max(env(safe-area-inset-top),2rem)]",
|
||||
actionBarAnim.topBarClass,
|
||||
)}
|
||||
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
||||
>
|
||||
<TurnNavigation />
|
||||
@@ -62,6 +67,7 @@ export function App() {
|
||||
onManagePlayers={() =>
|
||||
playerCharacterRef.current?.openManagement()
|
||||
}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -82,7 +88,10 @@ export function App() {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn("shrink-0 pb-8", actionBarAnim.settlingClass)}
|
||||
className={cn(
|
||||
"shrink-0 pb-[env(safe-area-inset-bottom)] sm:pb-[max(env(safe-area-inset-bottom),2rem)]",
|
||||
actionBarAnim.settlingClass,
|
||||
)}
|
||||
onAnimationEnd={actionBarAnim.onSettleEnd}
|
||||
>
|
||||
<ActionBar
|
||||
@@ -90,6 +99,7 @@ export function App() {
|
||||
onManagePlayers={() =>
|
||||
playerCharacterRef.current?.openManagement()
|
||||
}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -120,6 +130,10 @@ export function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingsModal
|
||||
open={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
<PlayerCharacterSection ref={playerCharacterRef} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
EncounterProvider,
|
||||
InitiativeRollsProvider,
|
||||
PlayerCharactersProvider,
|
||||
RulesEditionProvider,
|
||||
SidePanelProvider,
|
||||
ThemeProvider,
|
||||
} from "../contexts/index.js";
|
||||
@@ -12,17 +13,19 @@ import {
|
||||
export function AllProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<EncounterProvider>
|
||||
<BestiaryProvider>
|
||||
<PlayerCharactersProvider>
|
||||
<BulkImportProvider>
|
||||
<SidePanelProvider>
|
||||
<InitiativeRollsProvider>{children}</InitiativeRollsProvider>
|
||||
</SidePanelProvider>
|
||||
</BulkImportProvider>
|
||||
</PlayerCharactersProvider>
|
||||
</BestiaryProvider>
|
||||
</EncounterProvider>
|
||||
<RulesEditionProvider>
|
||||
<EncounterProvider>
|
||||
<BestiaryProvider>
|
||||
<PlayerCharactersProvider>
|
||||
<BulkImportProvider>
|
||||
<SidePanelProvider>
|
||||
<InitiativeRollsProvider>{children}</InitiativeRollsProvider>
|
||||
</SidePanelProvider>
|
||||
</BulkImportProvider>
|
||||
</PlayerCharactersProvider>
|
||||
</BestiaryProvider>
|
||||
</EncounterProvider>
|
||||
</RulesEditionProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -300,6 +300,44 @@ describe("normalizeBestiary", () => {
|
||||
expect(creatures[0].proficiencyBonus).toBe(6);
|
||||
});
|
||||
|
||||
it("normalizes pre-2024 {@atk mw} tags to full attack type text", () => {
|
||||
const raw = {
|
||||
monster: [
|
||||
{
|
||||
name: "Adult Black Dragon",
|
||||
source: "MM",
|
||||
size: ["H"],
|
||||
type: "dragon",
|
||||
ac: [19],
|
||||
hp: { average: 195, formula: "17d12 + 85" },
|
||||
speed: { walk: 40, fly: 80, swim: 40 },
|
||||
str: 23,
|
||||
dex: 14,
|
||||
con: 21,
|
||||
int: 14,
|
||||
wis: 13,
|
||||
cha: 17,
|
||||
passive: 21,
|
||||
cr: "14",
|
||||
action: [
|
||||
{
|
||||
name: "Bite",
|
||||
entries: [
|
||||
"{@atk mw} {@hit 11} to hit, reach 10 ft., one target. {@h}17 ({@damage 2d10 + 6}) piercing damage plus 4 ({@damage 1d8}) acid damage.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const creatures = normalizeBestiary(raw);
|
||||
const bite = creatures[0].actions?.[0];
|
||||
expect(bite?.text).toContain("Melee Weapon Attack:");
|
||||
expect(bite?.text).not.toContain("mw");
|
||||
expect(bite?.text).not.toContain("{@");
|
||||
});
|
||||
|
||||
it("handles fly speed with hover condition", () => {
|
||||
const raw = {
|
||||
monster: [
|
||||
|
||||
@@ -50,6 +50,26 @@ describe("stripTags", () => {
|
||||
expect(stripTags("{@atkr m,r}")).toBe("Melee or Ranged Attack Roll:");
|
||||
});
|
||||
|
||||
it("strips {@atk mw} to Melee Weapon Attack:", () => {
|
||||
expect(stripTags("{@atk mw}")).toBe("Melee Weapon Attack:");
|
||||
});
|
||||
|
||||
it("strips {@atk rw} to Ranged Weapon Attack:", () => {
|
||||
expect(stripTags("{@atk rw}")).toBe("Ranged Weapon Attack:");
|
||||
});
|
||||
|
||||
it("strips {@atk ms} to Melee Spell Attack:", () => {
|
||||
expect(stripTags("{@atk ms}")).toBe("Melee Spell Attack:");
|
||||
});
|
||||
|
||||
it("strips {@atk rs} to Ranged Spell Attack:", () => {
|
||||
expect(stripTags("{@atk rs}")).toBe("Ranged Spell Attack:");
|
||||
});
|
||||
|
||||
it("strips {@atk mw,rw} to Melee or Ranged Weapon Attack:", () => {
|
||||
expect(stripTags("{@atk mw,rw}")).toBe("Melee or Ranged Weapon Attack:");
|
||||
});
|
||||
|
||||
it("strips {@recharge 5} to (Recharge 5-6)", () => {
|
||||
expect(stripTags("{@recharge 5}")).toBe("(Recharge 5-6)");
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { type IDBPDatabase, openDB } from "idb";
|
||||
|
||||
const DB_NAME = "initiative-bestiary";
|
||||
const STORE_NAME = "sources";
|
||||
const DB_VERSION = 1;
|
||||
const DB_VERSION = 2;
|
||||
|
||||
export interface CachedSourceInfo {
|
||||
readonly sourceCode: string;
|
||||
@@ -32,12 +32,16 @@ async function getDb(): Promise<IDBPDatabase | null> {
|
||||
|
||||
try {
|
||||
db = await openDB(DB_NAME, DB_VERSION, {
|
||||
upgrade(database) {
|
||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||
upgrade(database, oldVersion, _newVersion, transaction) {
|
||||
if (oldVersion < 1) {
|
||||
database.createObjectStore(STORE_NAME, {
|
||||
keyPath: "sourceCode",
|
||||
});
|
||||
}
|
||||
if (oldVersion < 2 && database.objectStoreNames.contains(STORE_NAME)) {
|
||||
// Clear cached creatures to pick up improved tag processing
|
||||
transaction.objectStore(STORE_NAME).clear();
|
||||
}
|
||||
},
|
||||
});
|
||||
return db;
|
||||
|
||||
@@ -14,6 +14,15 @@ const ATKR_MAP: Record<string, string> = {
|
||||
"r,m": "Melee or Ranged Attack Roll:",
|
||||
};
|
||||
|
||||
const ATK_MAP: Record<string, string> = {
|
||||
mw: "Melee Weapon Attack:",
|
||||
rw: "Ranged Weapon Attack:",
|
||||
ms: "Melee Spell Attack:",
|
||||
rs: "Ranged Spell Attack:",
|
||||
"mw,rw": "Melee or Ranged Weapon Attack:",
|
||||
"rw,mw": "Melee or Ranged Weapon Attack:",
|
||||
};
|
||||
|
||||
/**
|
||||
* Strips 5etools {@tag ...} markup from text, converting to plain readable text.
|
||||
*
|
||||
@@ -51,11 +60,16 @@ export function stripTags(text: string): string {
|
||||
// {@hit N} → "+N"
|
||||
result = result.replaceAll(/\{@hit\s+(\d+)\}/g, "+$1");
|
||||
|
||||
// {@atkr type} → mapped attack roll text
|
||||
// {@atkr type} → mapped attack roll text (2024 rules)
|
||||
result = result.replaceAll(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
|
||||
return ATKR_MAP[type.trim()] ?? "Attack Roll:";
|
||||
});
|
||||
|
||||
// {@atk type} → mapped attack type text (pre-2024 data)
|
||||
result = result.replaceAll(/\{@atk\s+([^}]+)\}/g, (_, type: string) => {
|
||||
return ATK_MAP[type.trim()] ?? "Attack:";
|
||||
});
|
||||
|
||||
// {@actSave ability} → "Ability saving throw"
|
||||
result = result.replaceAll(
|
||||
/\{@actSave\s+([^}]+)\}/g,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRef, type RefObject } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { RulesEditionProvider } from "../../contexts/index.js";
|
||||
import { ConditionPicker } from "../condition-picker";
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -24,12 +25,14 @@ function renderPicker(
|
||||
document.body.appendChild(anchor);
|
||||
(anchorRef as { current: HTMLElement }).current = anchor;
|
||||
const result = render(
|
||||
<ConditionPicker
|
||||
anchorRef={anchorRef}
|
||||
activeConditions={overrides.activeConditions ?? []}
|
||||
onToggle={onToggle}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
<RulesEditionProvider>
|
||||
<ConditionPicker
|
||||
anchorRef={anchorRef}
|
||||
activeConditions={overrides.activeConditions ?? []}
|
||||
onToggle={onToggle}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</RulesEditionProvider>,
|
||||
);
|
||||
return { ...result, onToggle, onClose };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import {
|
||||
Check,
|
||||
Eye,
|
||||
@@ -6,26 +6,21 @@ import {
|
||||
Import,
|
||||
Library,
|
||||
Minus,
|
||||
Monitor,
|
||||
Moon,
|
||||
Plus,
|
||||
Sun,
|
||||
Settings,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import React, {
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { type RefObject, useCallback, useState } from "react";
|
||||
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
import { useThemeContext } from "../contexts/theme-context.js";
|
||||
import {
|
||||
creatureKey,
|
||||
type QueuedCreature,
|
||||
type SuggestionActions,
|
||||
useActionBarState,
|
||||
} from "../hooks/use-action-bar-state.js";
|
||||
import { useLongPress } from "../hooks/use-long-press.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { D20Icon } from "./d20-icon.js";
|
||||
@@ -35,19 +30,20 @@ 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;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface ActionBarProps {
|
||||
inputRef?: RefObject<HTMLInputElement | null>;
|
||||
autoFocus?: boolean;
|
||||
onManagePlayers?: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
function creatureKey(r: SearchResult): string {
|
||||
return `${r.source}:${r.name}`;
|
||||
interface AddModeSuggestionsProps {
|
||||
nameInput: string;
|
||||
suggestions: SearchResult[];
|
||||
pcMatches: PlayerCharacter[];
|
||||
suggestionIndex: number;
|
||||
queued: QueuedCreature | null;
|
||||
actions: SuggestionActions;
|
||||
}
|
||||
|
||||
function AddModeSuggestions({
|
||||
@@ -56,34 +52,15 @@ function AddModeSuggestions({
|
||||
pcMatches,
|
||||
suggestionIndex,
|
||||
queued,
|
||||
onDismiss,
|
||||
onClickSuggestion,
|
||||
onSetSuggestionIndex,
|
||||
onSetQueued,
|
||||
onConfirmQueued,
|
||||
onAddFromPlayerCharacter,
|
||||
onClear,
|
||||
}: Readonly<{
|
||||
nameInput: string;
|
||||
suggestions: SearchResult[];
|
||||
pcMatches: PlayerCharacter[];
|
||||
suggestionIndex: number;
|
||||
queued: QueuedCreature | null;
|
||||
onDismiss: () => void;
|
||||
onClear: () => void;
|
||||
onClickSuggestion: (result: SearchResult) => void;
|
||||
onSetSuggestionIndex: (i: number) => void;
|
||||
onSetQueued: (q: QueuedCreature | null) => void;
|
||||
onConfirmQueued: () => void;
|
||||
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||
}>) {
|
||||
actions,
|
||||
}: Readonly<AddModeSuggestionsProps>) {
|
||||
return (
|
||||
<div className="card-glow absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-lg border border-border bg-card">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1.5 border-border border-b px-3 py-2 text-left text-accent text-sm hover:bg-accent/20"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onDismiss}
|
||||
onClick={actions.dismiss}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<span className="flex-1">Add "{nameInput}" as custom</span>
|
||||
@@ -110,8 +87,8 @@ function AddModeSuggestions({
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
onAddFromPlayerCharacter?.(pc);
|
||||
onClear();
|
||||
actions.addFromPlayerCharacter?.(pc);
|
||||
actions.clear();
|
||||
}}
|
||||
>
|
||||
{!!PcIcon && (
|
||||
@@ -147,8 +124,8 @@ function AddModeSuggestions({
|
||||
"hover:bg-hover-neutral-bg",
|
||||
)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onClickSuggestion(result)}
|
||||
onMouseEnter={() => onSetSuggestionIndex(i)}
|
||||
onClick={() => actions.clickSuggestion(result)}
|
||||
onMouseEnter={() => actions.setSuggestionIndex(i)}
|
||||
>
|
||||
<span>{result.name}</span>
|
||||
<span className="flex items-center gap-1 text-muted-foreground text-xs">
|
||||
@@ -161,9 +138,9 @@ function AddModeSuggestions({
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (queued.count <= 1) {
|
||||
onSetQueued(null);
|
||||
actions.setQueued(null);
|
||||
} else {
|
||||
onSetQueued({
|
||||
actions.setQueued({
|
||||
...queued,
|
||||
count: queued.count - 1,
|
||||
});
|
||||
@@ -181,7 +158,7 @@ function AddModeSuggestions({
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSetQueued({
|
||||
actions.setQueued({
|
||||
...queued,
|
||||
count: queued.count + 1,
|
||||
});
|
||||
@@ -195,7 +172,7 @@ function AddModeSuggestions({
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConfirmQueued();
|
||||
actions.confirmQueued();
|
||||
}}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
@@ -216,17 +193,151 @@ function AddModeSuggestions({
|
||||
);
|
||||
}
|
||||
|
||||
const THEME_ICONS = {
|
||||
system: Monitor,
|
||||
light: Sun,
|
||||
dark: Moon,
|
||||
} as const;
|
||||
interface BrowseSuggestionsProps {
|
||||
suggestions: SearchResult[];
|
||||
suggestionIndex: number;
|
||||
onSelect: (result: SearchResult) => void;
|
||||
onHover: (index: number) => void;
|
||||
}
|
||||
|
||||
const THEME_LABELS = {
|
||||
system: "Theme: System",
|
||||
light: "Theme: Light",
|
||||
dark: "Theme: Dark",
|
||||
} as const;
|
||||
function BrowseSuggestions({
|
||||
suggestions,
|
||||
suggestionIndex,
|
||||
onSelect,
|
||||
onHover,
|
||||
}: Readonly<BrowseSuggestionsProps>) {
|
||||
if (suggestions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="card-glow absolute bottom-full z-50 mb-1 w-full rounded-lg border border-border bg-card">
|
||||
<ul className="max-h-48 overflow-y-auto py-1">
|
||||
{suggestions.map((result, i) => (
|
||||
<li key={creatureKey(result)}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between px-3 py-1.5 text-left text-sm",
|
||||
i === suggestionIndex
|
||||
? "bg-accent/20 text-foreground"
|
||||
: "text-foreground hover:bg-hover-neutral-bg",
|
||||
)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onSelect(result)}
|
||||
onMouseEnter={() => onHover(i)}
|
||||
>
|
||||
<span>{result.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{result.sourceDisplayName}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CustomStatFieldsProps {
|
||||
customInit: string;
|
||||
customAc: string;
|
||||
customMaxHp: string;
|
||||
onInitChange: (v: string) => void;
|
||||
onAcChange: (v: string) => void;
|
||||
onMaxHpChange: (v: string) => void;
|
||||
}
|
||||
|
||||
function CustomStatFields({
|
||||
customInit,
|
||||
customAc,
|
||||
customMaxHp,
|
||||
onInitChange,
|
||||
onAcChange,
|
||||
onMaxHpChange,
|
||||
}: Readonly<CustomStatFieldsProps>) {
|
||||
return (
|
||||
<div className="hidden items-center gap-2 sm:flex">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={customInit}
|
||||
onChange={(e) => onInitChange(e.target.value)}
|
||||
placeholder="Init"
|
||||
className="w-16 text-center"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={customAc}
|
||||
onChange={(e) => onAcChange(e.target.value)}
|
||||
placeholder="AC"
|
||||
className="w-16 text-center"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={customMaxHp}
|
||||
onChange={(e) => onMaxHpChange(e.target.value)}
|
||||
placeholder="MaxHP"
|
||||
className="w-18 text-center"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RollAllButton() {
|
||||
const { hasCreatureCombatants, canRollAllInitiative } = useEncounterContext();
|
||||
const { handleRollAllInitiative } = useInitiativeRollsContext();
|
||||
|
||||
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],
|
||||
),
|
||||
);
|
||||
|
||||
if (!hasCreatureCombatants) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-hover-action"
|
||||
onClick={() => handleRollAllInitiative()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openMenu(e.clientX, e.clientY);
|
||||
}}
|
||||
{...longPress}
|
||||
disabled={!canRollAllInitiative}
|
||||
title="Roll all initiative"
|
||||
aria-label="Roll all initiative"
|
||||
>
|
||||
<D20Icon className="h-6 w-6" />
|
||||
</Button>
|
||||
{!!menuPos && (
|
||||
<RollModeMenu
|
||||
position={menuPos}
|
||||
onSelect={(mode) => handleRollAllInitiative(mode)}
|
||||
onClose={() => setMenuPos(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function buildOverflowItems(opts: {
|
||||
onManagePlayers?: () => void;
|
||||
@@ -234,8 +345,7 @@ function buildOverflowItems(opts: {
|
||||
bestiaryLoaded: boolean;
|
||||
onBulkImport?: () => void;
|
||||
bulkImportDisabled?: boolean;
|
||||
themePreference?: "system" | "light" | "dark";
|
||||
onCycleTheme?: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
}): OverflowMenuItem[] {
|
||||
const items: OverflowMenuItem[] = [];
|
||||
if (opts.onManagePlayers) {
|
||||
@@ -260,14 +370,11 @@ function buildOverflowItems(opts: {
|
||||
disabled: opts.bulkImportDisabled,
|
||||
});
|
||||
}
|
||||
if (opts.onCycleTheme) {
|
||||
const pref = opts.themePreference ?? "system";
|
||||
const ThemeIcon = THEME_ICONS[pref];
|
||||
if (opts.onOpenSettings) {
|
||||
items.push({
|
||||
icon: <ThemeIcon className="h-4 w-4" />,
|
||||
label: THEME_LABELS[pref],
|
||||
onClick: opts.onCycleTheme,
|
||||
keepOpen: true,
|
||||
icon: <Settings className="h-4 w-4" />,
|
||||
label: "Settings",
|
||||
onClick: opts.onOpenSettings,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
@@ -277,270 +384,50 @@ export function ActionBar({
|
||||
inputRef,
|
||||
autoFocus,
|
||||
onManagePlayers,
|
||||
onOpenSettings,
|
||||
}: Readonly<ActionBarProps>) {
|
||||
const {
|
||||
addCombatant,
|
||||
addFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
hasCreatureCombatants,
|
||||
canRollAllInitiative,
|
||||
} = useEncounterContext();
|
||||
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
||||
useBestiaryContext();
|
||||
const { characters: playerCharacters } = usePlayerCharactersContext();
|
||||
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
||||
useSidePanelContext();
|
||||
const { preference: themePreference, cycleTheme } = useThemeContext();
|
||||
const { handleRollAllInitiative } = useInitiativeRollsContext();
|
||||
nameInput,
|
||||
suggestions,
|
||||
pcMatches,
|
||||
suggestionIndex,
|
||||
queued,
|
||||
customInit,
|
||||
customAc,
|
||||
customMaxHp,
|
||||
browseMode,
|
||||
bestiaryLoaded,
|
||||
hasSuggestions,
|
||||
showBulkImport,
|
||||
showSourceManager,
|
||||
suggestionActions,
|
||||
handleNameChange,
|
||||
handleKeyDown,
|
||||
handleBrowseKeyDown,
|
||||
handleAdd,
|
||||
handleBrowseSelect,
|
||||
toggleBrowseMode,
|
||||
setCustomInit,
|
||||
setCustomAc,
|
||||
setCustomMaxHp,
|
||||
} = useActionBarState();
|
||||
|
||||
const { state: bulkImportState } = useBulkImportContext();
|
||||
|
||||
const handleAddFromBestiary = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const creatureId = addFromBestiary(result);
|
||||
if (creatureId && panelView.mode === "closed") {
|
||||
showCreature(creatureId);
|
||||
}
|
||||
},
|
||||
[addFromBestiary, panelView.mode, showCreature],
|
||||
);
|
||||
|
||||
const handleViewStatBlock = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const slug = result.name
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
.replaceAll(/(^-|-$)/g, "");
|
||||
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
||||
showCreature(cId);
|
||||
},
|
||||
[showCreature],
|
||||
);
|
||||
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||
const deferredSuggestions = useDeferredValue(suggestions);
|
||||
const deferredPcMatches = useDeferredValue(pcMatches);
|
||||
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
||||
const [queued, setQueued] = useState<QueuedCreature | null>(null);
|
||||
const [customInit, setCustomInit] = useState("");
|
||||
const [customAc, setCustomAc] = useState("");
|
||||
const [customMaxHp, setCustomMaxHp] = useState("");
|
||||
const [browseMode, setBrowseMode] = useState(false);
|
||||
|
||||
const clearCustomFields = () => {
|
||||
setCustomInit("");
|
||||
setCustomAc("");
|
||||
setCustomMaxHp("");
|
||||
};
|
||||
|
||||
const clearInput = () => {
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
setQueued(null);
|
||||
setSuggestionIndex(-1);
|
||||
};
|
||||
|
||||
const dismissSuggestions = () => {
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
setQueued(null);
|
||||
setSuggestionIndex(-1);
|
||||
};
|
||||
|
||||
const confirmQueued = () => {
|
||||
if (!queued) return;
|
||||
for (let i = 0; i < queued.count; i++) {
|
||||
handleAddFromBestiary(queued.result);
|
||||
}
|
||||
clearInput();
|
||||
};
|
||||
|
||||
const parseNum = (v: string): number | undefined => {
|
||||
if (v.trim() === "") return undefined;
|
||||
const n = Number(v);
|
||||
return Number.isNaN(n) ? undefined : n;
|
||||
};
|
||||
|
||||
const handleAdd = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (browseMode) return;
|
||||
if (queued) {
|
||||
confirmQueued();
|
||||
return;
|
||||
}
|
||||
if (nameInput.trim() === "") return;
|
||||
const opts: { initiative?: number; ac?: number; maxHp?: number } = {};
|
||||
const init = parseNum(customInit);
|
||||
const ac = parseNum(customAc);
|
||||
const maxHp = parseNum(customMaxHp);
|
||||
if (init !== undefined) opts.initiative = init;
|
||||
if (ac !== undefined) opts.ac = ac;
|
||||
if (maxHp !== undefined) opts.maxHp = maxHp;
|
||||
addCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
clearCustomFields();
|
||||
};
|
||||
|
||||
const handleBrowseSearch = (value: string) => {
|
||||
setSuggestions(value.length >= 2 ? bestiarySearch(value) : []);
|
||||
};
|
||||
|
||||
const handleAddSearch = (value: string) => {
|
||||
let newSuggestions: SearchResult[] = [];
|
||||
let newPcMatches: PlayerCharacter[] = [];
|
||||
if (value.length >= 2) {
|
||||
newSuggestions = bestiarySearch(value);
|
||||
setSuggestions(newSuggestions);
|
||||
if (playerCharacters && playerCharacters.length > 0) {
|
||||
const lower = value.toLowerCase();
|
||||
newPcMatches = playerCharacters.filter((pc) =>
|
||||
pc.name.toLowerCase().includes(lower),
|
||||
);
|
||||
}
|
||||
setPcMatches(newPcMatches);
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
}
|
||||
if (newSuggestions.length > 0 || newPcMatches.length > 0) {
|
||||
clearCustomFields();
|
||||
}
|
||||
if (queued) {
|
||||
const qKey = creatureKey(queued.result);
|
||||
const stillVisible = newSuggestions.some((s) => creatureKey(s) === qKey);
|
||||
if (!stillVisible) {
|
||||
setQueued(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
setQueued({ ...queued, count: queued.count + 1 });
|
||||
} else {
|
||||
setQueued({ result, count: 1 });
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnter = () => {
|
||||
if (queued) {
|
||||
confirmQueued();
|
||||
} else if (suggestionIndex >= 0) {
|
||||
handleClickSuggestion(suggestions[suggestionIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
const hasSuggestions =
|
||||
deferredSuggestions.length > 0 || deferredPcMatches.length > 0;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!hasSuggestions) 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") {
|
||||
e.preventDefault();
|
||||
handleEnter();
|
||||
} else if (e.key === "Escape") {
|
||||
dismissSuggestions();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
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();
|
||||
handleViewStatBlock(suggestions[suggestionIndex]);
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseSelect = (result: SearchResult) => {
|
||||
handleViewStatBlock(result);
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
};
|
||||
|
||||
const toggleBrowseMode = () => {
|
||||
setBrowseMode((prev) => {
|
||||
const next = !prev;
|
||||
setSuggestionIndex(-1);
|
||||
setQueued(null);
|
||||
if (next) {
|
||||
handleBrowseSearch(nameInput);
|
||||
} else {
|
||||
handleAddSearch(nameInput);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
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({
|
||||
onManagePlayers,
|
||||
onOpenSourceManager: showSourceManager,
|
||||
bestiaryLoaded,
|
||||
onBulkImport: showBulkImport,
|
||||
bulkImportDisabled: bulkImportState.status === "loading",
|
||||
themePreference,
|
||||
onCycleTheme: cycleTheme,
|
||||
onOpenSettings,
|
||||
});
|
||||
|
||||
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 border-border border-t bg-card px-4 py-3 sm:rounded-lg sm:border">
|
||||
<form
|
||||
onSubmit={handleAdd}
|
||||
className="relative flex flex-1 items-center gap-2"
|
||||
className="relative flex flex-1 flex-wrap items-center gap-3 sm:flex-nowrap"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="relative max-w-xs">
|
||||
@@ -578,110 +465,40 @@ export function ActionBar({
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{browseMode && deferredSuggestions.length > 0 && (
|
||||
<div className="card-glow absolute bottom-full z-50 mb-1 w-full rounded-lg border border-border bg-card">
|
||||
<ul className="max-h-48 overflow-y-auto py-1">
|
||||
{deferredSuggestions.map((result, i) => (
|
||||
<li key={creatureKey(result)}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between px-3 py-1.5 text-left text-sm",
|
||||
i === suggestionIndex
|
||||
? "bg-accent/20 text-foreground"
|
||||
: "text-foreground hover:bg-hover-neutral-bg",
|
||||
)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => handleBrowseSelect(result)}
|
||||
onMouseEnter={() => setSuggestionIndex(i)}
|
||||
>
|
||||
<span>{result.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{result.sourceDisplayName}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{!!browseMode && (
|
||||
<BrowseSuggestions
|
||||
suggestions={suggestions}
|
||||
suggestionIndex={suggestionIndex}
|
||||
onSelect={handleBrowseSelect}
|
||||
onHover={suggestionActions.setSuggestionIndex}
|
||||
/>
|
||||
)}
|
||||
{!browseMode && hasSuggestions && (
|
||||
<AddModeSuggestions
|
||||
nameInput={nameInput}
|
||||
suggestions={deferredSuggestions}
|
||||
pcMatches={deferredPcMatches}
|
||||
suggestions={suggestions}
|
||||
pcMatches={pcMatches}
|
||||
suggestionIndex={suggestionIndex}
|
||||
queued={queued}
|
||||
onDismiss={dismissSuggestions}
|
||||
onClear={clearInput}
|
||||
onClickSuggestion={handleClickSuggestion}
|
||||
onSetSuggestionIndex={setSuggestionIndex}
|
||||
onSetQueued={setQueued}
|
||||
onConfirmQueued={confirmQueued}
|
||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||
actions={suggestionActions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={customInit}
|
||||
onChange={(e) => setCustomInit(e.target.value)}
|
||||
placeholder="Init"
|
||||
className="w-16 text-center"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={customAc}
|
||||
onChange={(e) => setCustomAc(e.target.value)}
|
||||
placeholder="AC"
|
||||
className="w-16 text-center"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={customMaxHp}
|
||||
onChange={(e) => setCustomMaxHp(e.target.value)}
|
||||
placeholder="MaxHP"
|
||||
className="w-18 text-center"
|
||||
/>
|
||||
</div>
|
||||
<CustomStatFields
|
||||
customInit={customInit}
|
||||
customAc={customAc}
|
||||
customMaxHp={customMaxHp}
|
||||
onInitChange={setCustomInit}
|
||||
onAcChange={setCustomAc}
|
||||
onMaxHpChange={setCustomMaxHp}
|
||||
/>
|
||||
)}
|
||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||
<Button type="submit">Add</Button>
|
||||
)}
|
||||
{!!hasCreatureCombatants && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-hover-action"
|
||||
onClick={() => handleRollAllInitiative()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openRollAllMenu(e.clientX, e.clientY);
|
||||
}}
|
||||
{...rollAllLongPress}
|
||||
disabled={!canRollAllInitiative}
|
||||
title="Roll all initiative"
|
||||
aria-label="Roll all initiative"
|
||||
>
|
||||
<D20Icon className="h-6 w-6" />
|
||||
</Button>
|
||||
{!!rollAllMenuPos && (
|
||||
<RollModeMenu
|
||||
position={rollAllMenuPos}
|
||||
onSelect={(mode) => handleRollAllInitiative(mode)}
|
||||
onClose={() => setRollAllMenuPos(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<RollAllButton />
|
||||
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -112,7 +112,7 @@ function EditableName({
|
||||
onClick={startEditing}
|
||||
title="Rename"
|
||||
aria-label="Rename"
|
||||
className="inline-flex shrink-0 items-center rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-colors transition-opacity hover:bg-hover-neutral-bg hover:text-hover-neutral focus:opacity-100 group-hover:opacity-100"
|
||||
className="inline-flex pointer-coarse:w-auto w-0 shrink-0 items-center overflow-hidden pointer-coarse:overflow-visible rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-all duration-150 hover:bg-hover-neutral-bg hover:text-hover-neutral focus:w-auto focus:overflow-visible focus:opacity-100 group-hover:w-auto group-hover:overflow-visible group-hover:opacity-100"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
@@ -157,7 +157,7 @@ function MaxHpDisplay({
|
||||
inputMode="numeric"
|
||||
value={draft}
|
||||
placeholder="Max"
|
||||
className="h-7 w-[7ch] text-center text-sm tabular-nums"
|
||||
className="h-7 w-[7ch] text-center tabular-nums"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
@@ -272,7 +272,7 @@ function AcDisplay({
|
||||
inputMode="numeric"
|
||||
value={draft}
|
||||
placeholder="AC"
|
||||
className="h-7 w-[6ch] text-center text-sm tabular-nums"
|
||||
className="h-7 w-[6ch] text-center tabular-nums"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
@@ -348,7 +348,7 @@ function InitiativeDisplay({
|
||||
value={draft}
|
||||
placeholder="--"
|
||||
className={cn(
|
||||
"h-7 w-full text-center text-sm tabular-nums",
|
||||
"h-7 w-full text-center tabular-nums",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
@@ -520,7 +520,7 @@ export function CombatantRow({
|
||||
isPulsing && "animate-concentration-pulse",
|
||||
)}
|
||||
>
|
||||
<div className="grid grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem] items-center gap-3 py-2">
|
||||
<div className="grid grid-cols-[2rem_3rem_auto_1fr_auto_2rem] items-center gap-1.5 py-3 sm:grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem] sm:gap-3 sm:py-2">
|
||||
{/* Concentration */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
|
||||
import {
|
||||
type ConditionId,
|
||||
getConditionDescription,
|
||||
getConditionsForEdition,
|
||||
} from "@initiative/domain";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowDown,
|
||||
@@ -13,12 +17,15 @@ import {
|
||||
Heart,
|
||||
Link,
|
||||
Moon,
|
||||
ShieldMinus,
|
||||
Siren,
|
||||
Snail,
|
||||
Sparkles,
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Tooltip } from "./ui/tooltip.js";
|
||||
|
||||
@@ -36,6 +43,8 @@ const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Droplet,
|
||||
ArrowDown,
|
||||
Link,
|
||||
ShieldMinus,
|
||||
Snail,
|
||||
Sparkles,
|
||||
Moon,
|
||||
};
|
||||
@@ -51,6 +60,7 @@ const COLOR_CLASSES: Record<string, string> = {
|
||||
slate: "text-slate-400",
|
||||
green: "text-green-400",
|
||||
indigo: "text-indigo-400",
|
||||
sky: "text-sky-400",
|
||||
};
|
||||
|
||||
interface ConditionPickerProps {
|
||||
@@ -104,6 +114,8 @@ export function ConditionPicker({
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [onClose]);
|
||||
|
||||
const { edition } = useRulesEditionContext();
|
||||
const conditions = getConditionsForEdition(edition);
|
||||
const active = new Set(activeConditions ?? []);
|
||||
|
||||
return createPortal(
|
||||
@@ -116,13 +128,17 @@ export function ConditionPicker({
|
||||
: { visibility: "hidden" as const }
|
||||
}
|
||||
>
|
||||
{CONDITION_DEFINITIONS.map((def) => {
|
||||
{conditions.map((def) => {
|
||||
const Icon = ICON_MAP[def.iconName];
|
||||
if (!Icon) return null;
|
||||
const isActive = active.has(def.id);
|
||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
return (
|
||||
<Tooltip key={def.id} content={def.description} className="block">
|
||||
<Tooltip
|
||||
key={def.id}
|
||||
content={getConditionDescription(def, edition)}
|
||||
className="block"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
|
||||
import {
|
||||
CONDITION_DEFINITIONS,
|
||||
type ConditionId,
|
||||
getConditionDescription,
|
||||
} from "@initiative/domain";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowDown,
|
||||
@@ -14,10 +18,13 @@ import {
|
||||
Link,
|
||||
Moon,
|
||||
Plus,
|
||||
ShieldMinus,
|
||||
Siren,
|
||||
Snail,
|
||||
Sparkles,
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { Tooltip } from "./ui/tooltip.js";
|
||||
|
||||
@@ -35,6 +42,8 @@ const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Droplet,
|
||||
ArrowDown,
|
||||
Link,
|
||||
ShieldMinus,
|
||||
Snail,
|
||||
Sparkles,
|
||||
Moon,
|
||||
};
|
||||
@@ -50,6 +59,7 @@ const COLOR_CLASSES: Record<string, string> = {
|
||||
slate: "text-slate-400",
|
||||
green: "text-green-400",
|
||||
indigo: "text-indigo-400",
|
||||
sky: "text-sky-400",
|
||||
};
|
||||
|
||||
interface ConditionTagsProps {
|
||||
@@ -63,6 +73,7 @@ export function ConditionTags({
|
||||
onRemove,
|
||||
onOpenPicker,
|
||||
}: Readonly<ConditionTagsProps>) {
|
||||
const { edition } = useRulesEditionContext();
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-0.5">
|
||||
{conditions?.map((condId) => {
|
||||
@@ -72,7 +83,10 @@ export function ConditionTags({
|
||||
if (!Icon) return null;
|
||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
return (
|
||||
<Tooltip key={condId} content={`${def.label}: ${def.description}`}>
|
||||
<Tooltip
|
||||
key={condId}
|
||||
content={`${def.label}:\n${getConditionDescription(def, edition)}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${def.label}`}
|
||||
@@ -94,7 +108,7 @@ export function ConditionTags({
|
||||
type="button"
|
||||
title="Add condition"
|
||||
aria-label="Add condition"
|
||||
className="inline-flex items-center rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-colors transition-opacity hover:bg-hover-neutral-bg hover:text-hover-neutral focus:opacity-100 group-hover:opacity-100"
|
||||
className="inline-flex pointer-coarse:w-auto w-0 items-center overflow-hidden pointer-coarse:overflow-visible rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-all duration-150 hover:bg-hover-neutral-bg hover:text-hover-neutral focus:w-auto focus:overflow-visible focus:opacity-100 group-hover:w-auto group-hover:overflow-visible group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenPicker();
|
||||
|
||||
@@ -106,7 +106,7 @@ export function HpAdjustPopover({
|
||||
inputMode="numeric"
|
||||
value={inputValue}
|
||||
placeholder="HP"
|
||||
className="h-7 w-[7ch] text-center text-sm tabular-nums"
|
||||
className="h-7 w-[7ch] text-center tabular-nums"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "" || DIGITS_ONLY_REGEX.test(v)) {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { RulesEdition } from "@initiative/domain";
|
||||
import { Monitor, Moon, Sun, X } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { useThemeContext } from "../contexts/theme-context.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const EDITION_OPTIONS: { value: RulesEdition; label: string }[] = [
|
||||
{ value: "5e", label: "5e (2014)" },
|
||||
{ value: "5.5e", label: "5.5e (2024)" },
|
||||
];
|
||||
|
||||
const THEME_OPTIONS: {
|
||||
value: "system" | "light" | "dark";
|
||||
label: string;
|
||||
icon: typeof Sun;
|
||||
}[] = [
|
||||
{ value: "system", label: "System", icon: Monitor },
|
||||
{ value: "light", label: "Light", icon: Sun },
|
||||
{ value: "dark", label: "Dark", icon: Moon },
|
||||
];
|
||||
|
||||
export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const { edition, setEdition } = useRulesEditionContext();
|
||||
const { preference, setPreference } = useThemeContext();
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) dialog.showModal();
|
||||
else if (!open && dialog.open) dialog.close();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
function handleCancel(e: Event) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === dialog) onClose();
|
||||
}
|
||||
dialog.addEventListener("cancel", handleCancel);
|
||||
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", handleCancel);
|
||||
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="card-glow m-auto w-full max-w-sm rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">Settings</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
||||
Conditions
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{EDITION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 rounded-md px-3 py-1.5 text-sm transition-colors",
|
||||
edition === opt.value
|
||||
? "bg-accent text-primary-foreground"
|
||||
: "bg-card text-muted-foreground hover:bg-hover-neutral-bg hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setEdition(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
||||
Theme
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{THEME_OPTIONS.map((opt) => {
|
||||
const Icon = opt.icon;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm transition-colors",
|
||||
preference === opt.value
|
||||
? "bg-accent text-primary-foreground"
|
||||
: "bg-card text-muted-foreground hover:bg-hover-neutral-bg hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setPreference(opt.value)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export function TurnNavigation() {
|
||||
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||
|
||||
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 border-border border-b bg-card px-4 py-3 sm:rounded-lg sm:border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -12,7 +12,7 @@ export const Input = ({
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-foreground text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50 sm:text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function Tooltip({
|
||||
createPortal(
|
||||
<div
|
||||
role="tooltip"
|
||||
className="pointer-events-none fixed z-[60] max-w-64 -translate-x-1/2 -translate-y-full rounded-md border border-border bg-background px-2.5 py-1.5 text-foreground text-xs leading-snug shadow-lg"
|
||||
className="pointer-events-none fixed z-[60] max-w-64 -translate-x-1/2 -translate-y-full whitespace-pre-line rounded-md border border-border bg-background px-2.5 py-1.5 text-foreground text-xs leading-snug shadow-lg"
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -3,5 +3,6 @@ export { BulkImportProvider } from "./bulk-import-context.js";
|
||||
export { EncounterProvider } from "./encounter-context.js";
|
||||
export { InitiativeRollsProvider } from "./initiative-rolls-context.js";
|
||||
export { PlayerCharactersProvider } from "./player-characters-context.js";
|
||||
export { RulesEditionProvider } from "./rules-edition-context.js";
|
||||
export { SidePanelProvider } from "./side-panel-context.js";
|
||||
export { ThemeProvider } from "./theme-context.js";
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { useRulesEdition } from "../hooks/use-rules-edition.js";
|
||||
|
||||
type RulesEditionContextValue = ReturnType<typeof useRulesEdition>;
|
||||
|
||||
const RulesEditionContext = createContext<RulesEditionContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export function RulesEditionProvider({ children }: { children: ReactNode }) {
|
||||
const value = useRulesEdition();
|
||||
return (
|
||||
<RulesEditionContext.Provider value={value}>
|
||||
{children}
|
||||
</RulesEditionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useRulesEditionContext(): RulesEditionContextValue {
|
||||
const ctx = useContext(RulesEditionContext);
|
||||
if (!ctx)
|
||||
throw new Error("useRulesEditionContext requires RulesEditionProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||
import { useCallback, useDeferredValue, useMemo, useState } from "react";
|
||||
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
|
||||
export interface QueuedCreature {
|
||||
result: SearchResult;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SuggestionActions {
|
||||
dismiss: () => void;
|
||||
clear: () => void;
|
||||
clickSuggestion: (result: SearchResult) => void;
|
||||
setSuggestionIndex: (i: number) => void;
|
||||
setQueued: (q: QueuedCreature | null) => void;
|
||||
confirmQueued: () => void;
|
||||
addFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||
}
|
||||
|
||||
export function creatureKey(r: SearchResult): string {
|
||||
return `${r.source}:${r.name}`;
|
||||
}
|
||||
|
||||
export function useActionBarState() {
|
||||
const { addCombatant, addFromBestiary, addFromPlayerCharacter } =
|
||||
useEncounterContext();
|
||||
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
||||
useBestiaryContext();
|
||||
const { characters: playerCharacters } = usePlayerCharactersContext();
|
||||
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
||||
useSidePanelContext();
|
||||
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||
const deferredSuggestions = useDeferredValue(suggestions);
|
||||
const deferredPcMatches = useDeferredValue(pcMatches);
|
||||
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
||||
const [queued, setQueued] = useState<QueuedCreature | null>(null);
|
||||
const [customInit, setCustomInit] = useState("");
|
||||
const [customAc, setCustomAc] = useState("");
|
||||
const [customMaxHp, setCustomMaxHp] = useState("");
|
||||
const [browseMode, setBrowseMode] = useState(false);
|
||||
|
||||
const clearCustomFields = () => {
|
||||
setCustomInit("");
|
||||
setCustomAc("");
|
||||
setCustomMaxHp("");
|
||||
};
|
||||
|
||||
const clearInput = useCallback(() => {
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
setQueued(null);
|
||||
setSuggestionIndex(-1);
|
||||
}, []);
|
||||
|
||||
const dismissSuggestions = useCallback(() => {
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
setQueued(null);
|
||||
setSuggestionIndex(-1);
|
||||
}, []);
|
||||
|
||||
const handleAddFromBestiary = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const creatureId = addFromBestiary(result);
|
||||
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
|
||||
if (creatureId && panelView.mode === "closed" && isDesktop) {
|
||||
showCreature(creatureId);
|
||||
}
|
||||
},
|
||||
[addFromBestiary, panelView.mode, showCreature],
|
||||
);
|
||||
|
||||
const handleViewStatBlock = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const slug = result.name
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
.replaceAll(/(^-|-$)/g, "");
|
||||
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
||||
showCreature(cId);
|
||||
},
|
||||
[showCreature],
|
||||
);
|
||||
|
||||
const confirmQueued = useCallback(() => {
|
||||
if (!queued) return;
|
||||
for (let i = 0; i < queued.count; i++) {
|
||||
handleAddFromBestiary(queued.result);
|
||||
}
|
||||
clearInput();
|
||||
}, [queued, handleAddFromBestiary, clearInput]);
|
||||
|
||||
const parseNum = (v: string): number | undefined => {
|
||||
if (v.trim() === "") return undefined;
|
||||
const n = Number(v);
|
||||
return Number.isNaN(n) ? undefined : n;
|
||||
};
|
||||
|
||||
const handleAdd = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (browseMode) return;
|
||||
if (queued) {
|
||||
confirmQueued();
|
||||
return;
|
||||
}
|
||||
if (nameInput.trim() === "") return;
|
||||
const opts: { initiative?: number; ac?: number; maxHp?: number } = {};
|
||||
const init = parseNum(customInit);
|
||||
const ac = parseNum(customAc);
|
||||
const maxHp = parseNum(customMaxHp);
|
||||
if (init !== undefined) opts.initiative = init;
|
||||
if (ac !== undefined) opts.ac = ac;
|
||||
if (maxHp !== undefined) opts.maxHp = maxHp;
|
||||
addCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
clearCustomFields();
|
||||
};
|
||||
|
||||
const handleBrowseSearch = (value: string) => {
|
||||
setSuggestions(value.length >= 2 ? bestiarySearch(value) : []);
|
||||
};
|
||||
|
||||
const handleAddSearch = (value: string) => {
|
||||
let newSuggestions: SearchResult[] = [];
|
||||
let newPcMatches: PlayerCharacter[] = [];
|
||||
if (value.length >= 2) {
|
||||
newSuggestions = bestiarySearch(value);
|
||||
setSuggestions(newSuggestions);
|
||||
if (playerCharacters && playerCharacters.length > 0) {
|
||||
const lower = value.toLowerCase();
|
||||
newPcMatches = playerCharacters.filter((pc) =>
|
||||
pc.name.toLowerCase().includes(lower),
|
||||
);
|
||||
}
|
||||
setPcMatches(newPcMatches);
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
}
|
||||
if (newSuggestions.length > 0 || newPcMatches.length > 0) {
|
||||
clearCustomFields();
|
||||
}
|
||||
if (queued) {
|
||||
const qKey = creatureKey(queued.result);
|
||||
const stillVisible = newSuggestions.some((s) => creatureKey(s) === qKey);
|
||||
if (!stillVisible) {
|
||||
setQueued(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setNameInput(value);
|
||||
setSuggestionIndex(-1);
|
||||
if (browseMode) {
|
||||
handleBrowseSearch(value);
|
||||
} else {
|
||||
handleAddSearch(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickSuggestion = useCallback((result: SearchResult) => {
|
||||
const key = creatureKey(result);
|
||||
setQueued((prev) => {
|
||||
if (prev && creatureKey(prev.result) === key) {
|
||||
return { ...prev, count: prev.count + 1 };
|
||||
}
|
||||
return { result, count: 1 };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleEnter = () => {
|
||||
if (queued) {
|
||||
confirmQueued();
|
||||
} else if (suggestionIndex >= 0) {
|
||||
handleClickSuggestion(suggestions[suggestionIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
const hasSuggestions =
|
||||
deferredSuggestions.length > 0 || deferredPcMatches.length > 0;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!hasSuggestions) 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") {
|
||||
e.preventDefault();
|
||||
handleEnter();
|
||||
} else if (e.key === "Escape") {
|
||||
dismissSuggestions();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
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();
|
||||
handleViewStatBlock(suggestions[suggestionIndex]);
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseSelect = (result: SearchResult) => {
|
||||
handleViewStatBlock(result);
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
};
|
||||
|
||||
const toggleBrowseMode = () => {
|
||||
setBrowseMode((prev) => {
|
||||
const next = !prev;
|
||||
setSuggestionIndex(-1);
|
||||
setQueued(null);
|
||||
if (next) {
|
||||
handleBrowseSearch(nameInput);
|
||||
} else {
|
||||
handleAddSearch(nameInput);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
clearCustomFields();
|
||||
};
|
||||
|
||||
const suggestionActions: SuggestionActions = useMemo(
|
||||
() => ({
|
||||
dismiss: dismissSuggestions,
|
||||
clear: clearInput,
|
||||
clickSuggestion: handleClickSuggestion,
|
||||
setSuggestionIndex,
|
||||
setQueued,
|
||||
confirmQueued,
|
||||
addFromPlayerCharacter,
|
||||
}),
|
||||
[
|
||||
dismissSuggestions,
|
||||
clearInput,
|
||||
handleClickSuggestion,
|
||||
confirmQueued,
|
||||
addFromPlayerCharacter,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
// State
|
||||
nameInput,
|
||||
suggestions: deferredSuggestions,
|
||||
pcMatches: deferredPcMatches,
|
||||
suggestionIndex,
|
||||
queued,
|
||||
customInit,
|
||||
customAc,
|
||||
customMaxHp,
|
||||
browseMode,
|
||||
bestiaryLoaded,
|
||||
hasSuggestions,
|
||||
showBulkImport,
|
||||
showSourceManager,
|
||||
|
||||
// Actions
|
||||
suggestionActions,
|
||||
handleNameChange,
|
||||
handleKeyDown,
|
||||
handleBrowseKeyDown,
|
||||
handleAdd,
|
||||
handleBrowseSelect,
|
||||
toggleBrowseMode,
|
||||
setCustomInit,
|
||||
setCustomAc,
|
||||
setCustomMaxHp,
|
||||
} as const;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
|
||||
@@ -8,10 +8,20 @@ export function useAutoStatBlock(): void {
|
||||
|
||||
const activeCreatureId =
|
||||
encounter.combatants[encounter.activeIndex]?.creatureId;
|
||||
const prevActiveIndexRef = useRef(encounter.activeIndex);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeCreatureId && panelView.mode === "creature") {
|
||||
const prevIndex = prevActiveIndexRef.current;
|
||||
prevActiveIndexRef.current = encounter.activeIndex;
|
||||
|
||||
// Only auto-update when the active turn changes (advance/retreat),
|
||||
// not when the panel mode changes (user clicking a different creature).
|
||||
if (
|
||||
encounter.activeIndex !== prevIndex &&
|
||||
activeCreatureId &&
|
||||
panelView.mode === "creature"
|
||||
) {
|
||||
updateCreature(activeCreatureId);
|
||||
}
|
||||
}, [activeCreatureId, panelView.mode, updateCreature]);
|
||||
}, [encounter.activeIndex, activeCreatureId, panelView.mode, updateCreature]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { RulesEdition } from "@initiative/domain";
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
const STORAGE_KEY = "initiative:rules-edition";
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let currentEdition: RulesEdition = loadEdition();
|
||||
|
||||
function loadEdition(): RulesEdition {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === "5e" || raw === "5.5e") return raw;
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
return "5.5e";
|
||||
}
|
||||
|
||||
function saveEdition(edition: RulesEdition): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, edition);
|
||||
} catch {
|
||||
// quota exceeded or storage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function notifyAll(): void {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(callback: () => void): () => void {
|
||||
listeners.add(callback);
|
||||
return () => listeners.delete(callback);
|
||||
}
|
||||
|
||||
function getSnapshot(): RulesEdition {
|
||||
return currentEdition;
|
||||
}
|
||||
|
||||
export function useRulesEdition() {
|
||||
const edition = useSyncExternalStore(subscribe, getSnapshot);
|
||||
|
||||
const setEdition = useCallback((next: RulesEdition) => {
|
||||
currentEdition = next;
|
||||
saveEdition(next);
|
||||
notifyAll();
|
||||
}, []);
|
||||
|
||||
return { edition, setEdition } as const;
|
||||
}
|
||||
@@ -39,6 +39,9 @@ function resolve(pref: ThemePreference): ResolvedTheme {
|
||||
|
||||
function applyTheme(resolved: ResolvedTheme): void {
|
||||
document.documentElement.dataset.theme = resolved;
|
||||
document
|
||||
.querySelector('meta[name="theme-color"]')
|
||||
?.setAttribute("content", resolved === "light" ? "#eeecea" : "#0e1a2e");
|
||||
}
|
||||
|
||||
function notifyAll(): void {
|
||||
@@ -71,8 +74,6 @@ function getSnapshot(): ThemePreference {
|
||||
return currentPreference;
|
||||
}
|
||||
|
||||
const CYCLE: ThemePreference[] = ["system", "light", "dark"];
|
||||
|
||||
export function useTheme() {
|
||||
const preference = useSyncExternalStore(subscribe, getSnapshot);
|
||||
const resolved = resolve(preference);
|
||||
@@ -88,11 +89,5 @@ export function useTheme() {
|
||||
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;
|
||||
return { preference, resolved, setPreference } as const;
|
||||
}
|
||||
|
||||
+16
-13
@@ -7,6 +7,7 @@ import {
|
||||
EncounterProvider,
|
||||
InitiativeRollsProvider,
|
||||
PlayerCharactersProvider,
|
||||
RulesEditionProvider,
|
||||
SidePanelProvider,
|
||||
ThemeProvider,
|
||||
} from "./contexts/index.js";
|
||||
@@ -17,19 +18,21 @@ if (root) {
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<EncounterProvider>
|
||||
<BestiaryProvider>
|
||||
<PlayerCharactersProvider>
|
||||
<BulkImportProvider>
|
||||
<SidePanelProvider>
|
||||
<InitiativeRollsProvider>
|
||||
<App />
|
||||
</InitiativeRollsProvider>
|
||||
</SidePanelProvider>
|
||||
</BulkImportProvider>
|
||||
</PlayerCharactersProvider>
|
||||
</BestiaryProvider>
|
||||
</EncounterProvider>
|
||||
<RulesEditionProvider>
|
||||
<EncounterProvider>
|
||||
<BestiaryProvider>
|
||||
<PlayerCharactersProvider>
|
||||
<BulkImportProvider>
|
||||
<SidePanelProvider>
|
||||
<InitiativeRollsProvider>
|
||||
<App />
|
||||
</InitiativeRollsProvider>
|
||||
</SidePanelProvider>
|
||||
</BulkImportProvider>
|
||||
</PlayerCharactersProvider>
|
||||
</BestiaryProvider>
|
||||
</EncounterProvider>
|
||||
</RulesEditionProvider>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# ADR-NNN: [Title]
|
||||
|
||||
**Date**: YYYY-MM-DD
|
||||
**Status**: accepted | superseded | deprecated
|
||||
|
||||
## Context
|
||||
|
||||
What is the problem or situation that motivates this decision?
|
||||
|
||||
## Decision
|
||||
|
||||
What did we decide, and why?
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
What other approaches were evaluated?
|
||||
|
||||
## Consequences
|
||||
|
||||
What are the trade-offs — both positive and negative?
|
||||
@@ -0,0 +1,45 @@
|
||||
# ADR-001: Errors as Values, Not Exceptions
|
||||
|
||||
**Date**: 2026-03-25
|
||||
**Status**: accepted
|
||||
|
||||
## Context
|
||||
|
||||
Domain functions need to communicate failure (invalid input, missing combatant, violated invariants). The standard JavaScript approach is to throw exceptions, but thrown exceptions are invisible to TypeScript's type system — nothing in a function's signature tells the caller that it can fail or what errors to expect.
|
||||
|
||||
This project's domain layer is designed to be pure and deterministic. Thrown exceptions break both properties: they alter control flow (a side effect) and make the function's output unpredictable from the caller's perspective.
|
||||
|
||||
## Decision
|
||||
|
||||
All domain functions return `SuccessType | DomainError` unions. `DomainError` is a plain data object with a `kind` discriminant, a machine-readable `code`, and a human-readable `message`:
|
||||
|
||||
```typescript
|
||||
interface DomainError {
|
||||
readonly kind: "domain-error";
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
}
|
||||
```
|
||||
|
||||
Callers check results with the `isDomainError()` type guard before accessing success data. Errors are never thrown in the domain layer (adapter-layer code may throw for programmer errors like missing providers).
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
**Thrown exceptions** — the JavaScript default. Simpler to write (`throw new Error(...)`) but error paths are invisible to the type system. The caller has no compile-time indication that a function can fail, and `catch` blocks lose type information about which errors are possible. Would also make domain functions impure.
|
||||
|
||||
**Result wrapper types** (e.g., `neverthrow`, `ts-results`) — formalizes the pattern with `.map()`, `.unwrap()`, `.match()` methods. More ergonomic for chaining operations, but adds a library dependency and a layer of indirection. The project's use cases are simple enough (call domain function, check error, save or return) that raw unions are sufficient.
|
||||
|
||||
**Validation libraries** (Zod, io-ts) — useful for input parsing but don't cover domain logic errors like "combatant not found" or "no previous turn". Would only address a subset of the problem.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Error handling is compiler-enforced. Forgetting to check for an error produces a type error when accessing success fields.
|
||||
- Domain functions remain pure — they return data, never alter control flow.
|
||||
- Error codes are stable, machine-readable identifiers that UI code can match on.
|
||||
- Testing is straightforward: assert the return value, no try/catch in tests.
|
||||
|
||||
**Negative:**
|
||||
- Every call site must check `isDomainError()` before proceeding. This is slightly more verbose than a try/catch that wraps multiple calls.
|
||||
- Composing multiple fallible operations requires manual chaining (check error, then call next function). A Result wrapper would make this more ergonomic if the codebase grows significantly.
|
||||
- Contributors familiar with JavaScript conventions may initially find the pattern unfamiliar.
|
||||
@@ -0,0 +1,46 @@
|
||||
# ADR-002: Domain Events as Plain Data Objects
|
||||
|
||||
**Date**: 2026-03-25
|
||||
**Status**: accepted
|
||||
|
||||
## Context
|
||||
|
||||
Domain state transitions need to communicate what happened (not just the new state) so the UI layer can react — showing toasts, auto-scrolling, opening panels, etc. The project needs an event mechanism that stays consistent with the pure, deterministic domain core.
|
||||
|
||||
## Decision
|
||||
|
||||
Domain events are plain data objects with a `type` string discriminant. They form a discriminated union (`DomainEvent`) of 18 event types. Events are returned alongside the new state from domain functions, not emitted through a pub/sub system:
|
||||
|
||||
```typescript
|
||||
// Example event
|
||||
{ type: "TurnAdvanced", previousCombatantId: "abc", newCombatantId: "def", roundNumber: 2 }
|
||||
|
||||
// Domain function returns both state and events
|
||||
function advanceTurn(encounter: Encounter): { encounter: Encounter; events: DomainEvent[] } | DomainError
|
||||
```
|
||||
|
||||
Events are consumed ephemerally by the UI layer and are not persisted.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
**Class-based events** (e.g., `class TurnAdvanced extends DomainEvent { ... }`) — common in OOP-style domain-driven design. Adds inheritance hierarchies, constructors, and `instanceof` checks. No benefit here: TypeScript's discriminated union narrowing (`switch (event.type)`) provides the same exhaustiveness checking without classes. Classes also can't be serialized/deserialized without custom logic.
|
||||
|
||||
**Event emitter / pub-sub** (Node `EventEmitter`, custom bus, RxJS) — events are broadcast and listeners subscribe. Decouples producers from consumers, but introduces implicit coupling (who's listening?), ordering concerns, and makes the domain impure (emitting is a side effect). Harder to test — you'd need to set up listeners and collect results instead of just asserting on a return value.
|
||||
|
||||
**Observable streams** (RxJS) — powerful for async event processing and composition. Massive overkill for this use case: events are synchronous, produced one batch at a time, and consumed immediately. Would add a significant dependency and conceptual overhead.
|
||||
|
||||
**No events** (just compare old and new state) — the UI could diff states to determine what changed. Works for simple cases, but can't express intent (did HP drop because of damage or because max HP was lowered?) and gets unwieldy as the state model grows.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Events are serializable (JSON-compatible). If the project ever adds undo/redo or event logging, no changes to the event format are needed.
|
||||
- TypeScript's `switch (event.type)` provides exhaustiveness checking — the compiler warns if a new event type is added but not handled.
|
||||
- No framework coupling. Events are just data; any consumer (React, a test, a CLI) can process them identically.
|
||||
- Domain functions remain pure — events are returned, not emitted.
|
||||
- Testing is trivial: assert that `result.events` contains the expected objects.
|
||||
|
||||
**Negative:**
|
||||
- Events are currently consumed and discarded. There is no event log, replay, or undo capability. The architecture supports it, but it's not built.
|
||||
- Adding a new event type requires updating the `DomainEvent` union, which touches a central file. This is intentional (forces explicit acknowledgment) but adds friction.
|
||||
- No built-in mechanism for event handlers to communicate back (e.g., "veto this event"). Events are informational, not transactional.
|
||||
@@ -0,0 +1,256 @@
|
||||
---
|
||||
date: "2026-03-24T10:22:04.341906+00:00"
|
||||
git_commit: cfd4aef724487a681e425cedfa08f3e89255f91a
|
||||
branch: main
|
||||
topic: "Rules edition setting for condition tooltips + settings modal"
|
||||
tags: [research, codebase, conditions, settings, theme, modal, issue-12]
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Research: Rules Edition Setting for Condition Tooltips + Settings Modal
|
||||
|
||||
## Research Question
|
||||
|
||||
Map the codebase for implementing issue #12: a rules edition setting (5e 2014 / 5.5e 2024) that controls condition tooltip descriptions, delivered via a new settings modal that also absorbs the existing theme toggle. Target spec: `specs/003-combatant-state/spec.md` (stories CC-3, CC-8, FR-095–FR-102).
|
||||
|
||||
## Summary
|
||||
|
||||
The implementation touches five areas: (1) the domain condition definitions, (2) the tooltip rendering in two web components, (3) the kebab overflow menu in the action bar, (4) the theme system (hook + context), and (5) a new settings modal following existing `<dialog>` patterns. The localStorage persistence pattern is well-established with a consistent `"initiative:<key>"` convention. The context provider tree in `main.tsx` is the integration point for a new settings context.
|
||||
|
||||
## Detailed Findings
|
||||
|
||||
### 1. Condition Definitions and Tooltip Data Flow
|
||||
|
||||
**Domain layer** — `packages/domain/src/conditions.ts`
|
||||
|
||||
The `ConditionDefinition` interface (line 18) carries a single `description: string` field. The `CONDITION_DEFINITIONS` array (line 26) holds all 15 conditions as `readonly` objects with `id`, `label`, `description`, `iconName`, and `color`. This is the single source of truth for condition data.
|
||||
|
||||
Exported types: `ConditionId` (union of 15 string literals), `ConditionDefinition`, `CONDITION_DEFINITIONS`, `VALID_CONDITION_IDS`.
|
||||
|
||||
**Web layer — condition-tags.tsx** (`apps/web/src/components/condition-tags.tsx`)
|
||||
|
||||
- Line 69: Looks up definition via `CONDITION_DEFINITIONS.find((d) => d.id === condId)`
|
||||
- Line 75: Passes tooltip content as `` `${def.label}: ${def.description}` `` — combines label + description into a single string
|
||||
- This is the tooltip shown when hovering active condition icons in the combatant row
|
||||
|
||||
**Web layer — condition-picker.tsx** (`apps/web/src/components/condition-picker.tsx`)
|
||||
|
||||
- Line 119: Iterates `CONDITION_DEFINITIONS.map(...)` directly
|
||||
- Line 125: Passes `content={def.description}` to Tooltip — description only, no label prefix
|
||||
- This is the tooltip shown when hovering conditions in the dropdown picker
|
||||
|
||||
**Key observation:** Both components read `def.description` directly from the imported domain constant. To make descriptions edition-aware, either (a) the domain type needs dual descriptions and consumers select by edition, or (b) a higher-level hook resolves the correct description before passing to components.
|
||||
|
||||
### 2. Tooltip Component
|
||||
|
||||
**File:** `apps/web/src/components/ui/tooltip.tsx`
|
||||
|
||||
- Props: `content: string`, `children: ReactNode`, optional `className`
|
||||
- Positioning: Uses `getBoundingClientRect()` to place tooltip 4px above the trigger element, centered horizontally
|
||||
- Rendered via `createPortal` to `document.body` at z-index 60
|
||||
- Max width: `max-w-64` (256px / 16rem) with `text-xs leading-snug`
|
||||
- Text wraps naturally within the max-width constraint — no explicit truncation
|
||||
- The tooltip accepts only `string` content, not ReactNode
|
||||
|
||||
The current descriptions are short (1-2 sentences). The 5e (2014) exhaustion description will be longer (6-level table as text), which may benefit from the existing 256px wrapping. No changes to the tooltip component itself should be needed.
|
||||
|
||||
### 3. Kebab Menu (Overflow Menu)
|
||||
|
||||
**OverflowMenu component** — `apps/web/src/components/ui/overflow-menu.tsx`
|
||||
|
||||
- Generic menu component accepting `items: readonly OverflowMenuItem[]`
|
||||
- Each item has: `icon: ReactNode`, `label: string`, `onClick: () => void`, optional `disabled` and `keepOpen`
|
||||
- Opens upward (`bottom-full`) from the kebab button, right-aligned
|
||||
- Close on click-outside (mousedown) and Escape key
|
||||
|
||||
**ActionBar integration** — `apps/web/src/components/action-bar.tsx`
|
||||
|
||||
- `buildOverflowItems()` function (line 231) constructs the menu items array
|
||||
- Current items in order:
|
||||
1. **Player Characters** (Users icon) — calls `opts.onManagePlayers`
|
||||
2. **Manage Sources** (Library icon) — calls `opts.onOpenSourceManager`
|
||||
3. **Import All Sources** (Import icon) — conditional on bestiary loaded
|
||||
4. **Theme cycle** (Monitor/Sun/Moon icon) — calls `opts.onCycleTheme`, uses `keepOpen: true`
|
||||
- Theme constants at lines 219-229: `THEME_ICONS` and `THEME_LABELS` maps
|
||||
- Line 293: `useThemeContext()` provides `preference` and `cycleTheme`
|
||||
- Line 529-537: Overflow items built with all options passed in
|
||||
|
||||
**To add a "Settings" item:** Add a new entry to `buildOverflowItems()` and remove the theme cycle entry. The new item would call a callback to open the settings modal.
|
||||
|
||||
### 4. Theme System
|
||||
|
||||
**Hook** — `apps/web/src/hooks/use-theme.ts`
|
||||
|
||||
- Module-level state: `currentPreference` initialized from localStorage on import (line 9)
|
||||
- `ThemePreference` type: `"system" | "light" | "dark"`
|
||||
- `ResolvedTheme` type: `"light" | "dark"`
|
||||
- Storage key: `"initiative:theme"` (line 6)
|
||||
- `loadPreference()` — reads localStorage, defaults to `"system"` (lines 11-19)
|
||||
- `savePreference()` — writes to localStorage, silent on error (lines 21-27)
|
||||
- `resolve()` — resolves "system" via `matchMedia("(prefers-color-scheme: light)")` (lines 29-38)
|
||||
- `applyTheme()` — sets `document.documentElement.dataset.theme` (lines 40-42)
|
||||
- Uses `useSyncExternalStore` for React integration (line 77)
|
||||
- Exposes: `preference`, `resolved`, `setPreference`, `cycleTheme`
|
||||
- OS preference change listener updates theme when preference is "system" (lines 54-63)
|
||||
|
||||
**Context** — `apps/web/src/contexts/theme-context.tsx`
|
||||
|
||||
- Simple wrapper: `ThemeProvider` calls `useTheme()` and provides via React context
|
||||
- `useThemeContext()` hook for consumers (line 15)
|
||||
|
||||
**For settings modal:** The theme system already exposes `setPreference(pref)` which is exactly what the settings modal needs — direct selection instead of cycling.
|
||||
|
||||
### 5. localStorage Persistence Patterns
|
||||
|
||||
All storage follows a consistent pattern:
|
||||
|
||||
| Key | Content | Format |
|
||||
|-----|---------|--------|
|
||||
| `initiative:encounter` | Full encounter state | JSON object |
|
||||
| `initiative:player-characters` | Player character array | JSON array |
|
||||
| `initiative:theme` | Theme preference | Plain string |
|
||||
|
||||
**Common patterns:**
|
||||
- Read: `try { localStorage.getItem(key) } catch { return default }`
|
||||
- Write: `try { localStorage.setItem(key, value) } catch { /* silent */ }`
|
||||
- Validation on read: type-check, range-check, reject invalid, return fallback
|
||||
- Bootstrap: `useState(initializeFunction)` where initializer loads from storage
|
||||
- Persistence: `useEffect([data], () => saveToStorage(data))`
|
||||
|
||||
**For rules edition:** Key would be `"initiative:rules-edition"`. Value would be a plain string (`"5e"` or `"5.5e"`), matching the theme pattern (simple string, not JSON). Default: `"5.5e"`.
|
||||
|
||||
### 6. Modal Patterns
|
||||
|
||||
Two modal implementations exist, both using HTML `<dialog>`:
|
||||
|
||||
**PlayerManagement** (`apps/web/src/components/player-management.tsx`)
|
||||
- Controlled by `open` prop
|
||||
- `useEffect` calls `dialog.showModal()` / `dialog.close()` based on `open`
|
||||
- Cancel event (Escape) prevented and routed to `onClose`
|
||||
- Backdrop click (mousedown on dialog element itself) routes to `onClose`
|
||||
- Styling: `card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50`
|
||||
- Header: title + X close button (ghost variant, muted foreground)
|
||||
|
||||
**CreatePlayerModal** (`apps/web/src/components/create-player-modal.tsx`)
|
||||
- Same `<dialog>` pattern with identical open/close/cancel/backdrop handling
|
||||
- Has form submission with validation and error display
|
||||
- Same styling as PlayerManagement
|
||||
|
||||
**Shared dialog pattern (extract from both):**
|
||||
```tsx
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) dialog.showModal();
|
||||
else if (!open && dialog.open) dialog.close();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
const handleCancel = (e: Event) => { e.preventDefault(); onClose(); };
|
||||
const handleBackdropClick = (e: MouseEvent) => { if (e.target === dialog) onClose(); };
|
||||
dialog.addEventListener("cancel", handleCancel);
|
||||
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||
return () => { /* cleanup */ };
|
||||
}, [onClose]);
|
||||
```
|
||||
|
||||
### 7. Context Provider Tree
|
||||
|
||||
**File:** `apps/web/src/main.tsx`
|
||||
|
||||
Provider nesting order (outermost first):
|
||||
1. `ThemeProvider`
|
||||
2. `EncounterProvider`
|
||||
3. `BestiaryProvider`
|
||||
4. `PlayerCharactersProvider`
|
||||
5. `BulkImportProvider`
|
||||
6. `SidePanelProvider`
|
||||
7. `InitiativeRollsProvider`
|
||||
|
||||
A new `SettingsProvider` (or `RulesEditionProvider`) would slot in early — before any component that reads condition descriptions. Since `ThemeProvider` is already the outermost, and the settings modal manages both theme and rules edition, one option is a `SettingsProvider` that wraps or replaces `ThemeProvider`.
|
||||
|
||||
### 8. 5e vs 5.5e Condition Text Differences
|
||||
|
||||
Based on research, here are the conditions with meaningful mechanical differences between editions. Conditions not listed are functionally identical across editions.
|
||||
|
||||
**Major changes:**
|
||||
|
||||
| Condition | 5e (2014) | 5.5e (2024) — current text |
|
||||
|---|---|---|
|
||||
| **Exhaustion** | 6 escalating levels: L1 disadvantage on ability checks, L2 speed halved, L3 disadvantage on attacks/saves, L4 HP max halved, L5 speed 0, L6 death | −level from d20 tests and spell save DCs. Speed reduced by 5 ft. × level. Death at 10 levels. (current) |
|
||||
| **Grappled** | Speed 0. Ends if grappler incapacitated or moved out of reach. | Speed 0, can't benefit from speed bonuses. Ends if grappler incapacitated or moved out of reach. (current — but 2024 also adds disadvantage on attacks vs non-grappler) |
|
||||
| **Invisible** | Can't be seen without magic/special sense. Heavily obscured. Advantage on attacks; disadvantage on attacks against. | 2024 broadened: can be gained from Hide action; grants Surprise (advantage on initiative), Concealed (unaffected by sight effects), attacks advantage/disadvantage. (current text is closer to 2014) |
|
||||
| **Stunned** | Incapacitated. Can't move. Speak falteringly. Auto-fail Str/Dex saves. Attacks against have advantage. | 2024: same but can still move (controversial). (current text says "Can't move" — matches 2014) |
|
||||
|
||||
**Moderate changes:**
|
||||
|
||||
| Condition | 5e (2014) | 5.5e (2024) |
|
||||
|---|---|---|
|
||||
| **Incapacitated** | Can't take actions or reactions. | Can't take actions, bonus actions, or reactions. Speed 0. Auto-fail Str/Dex saves. Attacks against have advantage. Concentration broken. (current is partial 2024) |
|
||||
| **Petrified** | Unaware of surroundings. | Aware of surroundings (2024 change). Current text doesn't mention awareness. |
|
||||
| **Poisoned** | Disadvantage on attacks and ability checks. | Same, but 2024 consolidates disease into poisoned. |
|
||||
|
||||
**Minor/identical:**
|
||||
|
||||
Blinded, Charmed ("harmful" → "damaging"), Deafened, Frightened, Paralyzed, Prone, Restrained, Unconscious — functionally identical between editions.
|
||||
|
||||
**Note on current descriptions:** The existing `conditions.ts` descriptions are a mix — exhaustion is clearly 2024, but stunned says "Can't move" which matches 2014. A full audit of each description against both editions will be needed during implementation to ensure accuracy.
|
||||
|
||||
## Code References
|
||||
|
||||
- `packages/domain/src/conditions.ts:18-24` — `ConditionDefinition` interface (single `description` field)
|
||||
- `packages/domain/src/conditions.ts:26-145` — `CONDITION_DEFINITIONS` array with current (mixed edition) descriptions
|
||||
- `apps/web/src/components/condition-tags.tsx:75` — Tooltip with `${def.label}: ${def.description}`
|
||||
- `apps/web/src/components/condition-picker.tsx:125` — Tooltip with `def.description`
|
||||
- `apps/web/src/components/ui/tooltip.tsx:1-55` — Tooltip component (string content, 256px max-width)
|
||||
- `apps/web/src/components/ui/overflow-menu.tsx:1-73` — Generic overflow menu
|
||||
- `apps/web/src/components/action-bar.tsx:231-274` — `buildOverflowItems()` (current menu items)
|
||||
- `apps/web/src/components/action-bar.tsx:293` — `useThemeContext()` usage in ActionBar
|
||||
- `apps/web/src/hooks/use-theme.ts:1-98` — Theme hook with localStorage, `useSyncExternalStore`, cycle/set
|
||||
- `apps/web/src/contexts/theme-context.tsx:1-19` — Theme context provider
|
||||
- `apps/web/src/main.tsx:17-35` — Provider nesting order
|
||||
- `apps/web/src/components/player-management.tsx:55-131` — `<dialog>` modal pattern (reference for settings modal)
|
||||
- `apps/web/src/components/create-player-modal.tsx:106-191` — Form-based `<dialog>` modal pattern
|
||||
- `apps/web/src/persistence/encounter-storage.ts` — localStorage persistence pattern (read/write/validate)
|
||||
- `apps/web/src/persistence/player-character-storage.ts` — localStorage persistence pattern
|
||||
|
||||
## Architecture Documentation
|
||||
|
||||
### Data Flow: Condition Description → Tooltip
|
||||
|
||||
```
|
||||
Domain: CONDITION_DEFINITIONS[].description (single string)
|
||||
↓ imported by
|
||||
Web: condition-tags.tsx → Tooltip content={`${label}: ${description}`}
|
||||
Web: condition-picker.tsx → Tooltip content={description}
|
||||
↓ rendered by
|
||||
UI: tooltip.tsx → createPortal → fixed-position div (max-w-64)
|
||||
```
|
||||
|
||||
### Settings/Preference Architecture
|
||||
|
||||
```
|
||||
localStorage → use-theme.ts (useSyncExternalStore) → theme-context.tsx → consumers
|
||||
localStorage → encounter-storage.ts → use-encounter.ts (useState) → encounter-context.tsx
|
||||
localStorage → player-character-storage.ts → use-player-characters.ts (useState) → pc-context.tsx
|
||||
```
|
||||
|
||||
### Modal Triggering Pattern
|
||||
|
||||
```
|
||||
ActionBar overflow menu item click
|
||||
→ callback prop (e.g., onManagePlayers)
|
||||
→ App.tsx calls imperative handle (e.g., playerCharacterRef.current.openManagement())
|
||||
→ Section component sets open state
|
||||
→ <dialog>.showModal() via useEffect
|
||||
```
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Current description accuracy:** The existing descriptions are a mix of 2014 and 2024 text (e.g., exhaustion is 2024, stunned "Can't move" is 2014). Both sets of descriptions need careful authoring against official sources during implementation.
|
||||
2. **Domain type change:** Should `ConditionDefinition` carry `description5e` and `description55e` fields, or should description resolution happen at the application/web layer? The domain-level approach is simpler and keeps the data co-located with condition definitions.
|
||||
3. **Settings context scope:** Should a new `SettingsProvider` manage both rules edition and theme, or should rules edition be its own context? The theme system already has its own well-structured hook/context; combining them may add unnecessary coupling.
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CONDITION_DEFINITIONS,
|
||||
getConditionDescription,
|
||||
getConditionsForEdition,
|
||||
} from "../conditions.js";
|
||||
|
||||
function findCondition(id: string) {
|
||||
const def = CONDITION_DEFINITIONS.find((d) => d.id === id);
|
||||
if (!def) throw new Error(`Condition ${id} not found`);
|
||||
return def;
|
||||
}
|
||||
|
||||
describe("getConditionDescription", () => {
|
||||
it("returns 5.5e description by default", () => {
|
||||
const exhaustion = findCondition("exhaustion");
|
||||
expect(getConditionDescription(exhaustion, "5.5e")).toBe(
|
||||
exhaustion.description,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 5e description when edition is 5e", () => {
|
||||
const exhaustion = findCondition("exhaustion");
|
||||
expect(getConditionDescription(exhaustion, "5e")).toBe(
|
||||
exhaustion.description5e,
|
||||
);
|
||||
});
|
||||
|
||||
it("universal conditions have both descriptions", () => {
|
||||
const universal = CONDITION_DEFINITIONS.filter(
|
||||
(d) => d.edition === undefined,
|
||||
);
|
||||
expect(universal.length).toBeGreaterThan(0);
|
||||
for (const def of universal) {
|
||||
expect(def.description).toBeTruthy();
|
||||
expect(def.description5e).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("edition-specific conditions have their edition description", () => {
|
||||
const sapped = findCondition("sapped");
|
||||
expect(sapped.description).toBeTruthy();
|
||||
expect(sapped.edition).toBe("5.5e");
|
||||
|
||||
const slowed = findCondition("slowed");
|
||||
expect(slowed.description).toBeTruthy();
|
||||
expect(slowed.edition).toBe("5.5e");
|
||||
});
|
||||
|
||||
it("conditions with identical rules share the same text", () => {
|
||||
const blinded = findCondition("blinded");
|
||||
expect(blinded.description).toBe(blinded.description5e);
|
||||
});
|
||||
|
||||
it("conditions with different rules have different text", () => {
|
||||
const exhaustion = findCondition("exhaustion");
|
||||
expect(exhaustion.description).not.toBe(exhaustion.description5e);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConditionsForEdition", () => {
|
||||
it("includes sapped and slowed for 5.5e", () => {
|
||||
const conditions = getConditionsForEdition("5.5e");
|
||||
const ids = conditions.map((d) => d.id);
|
||||
expect(ids).toContain("sapped");
|
||||
expect(ids).toContain("slowed");
|
||||
});
|
||||
|
||||
it("excludes sapped and slowed for 5e", () => {
|
||||
const conditions = getConditionsForEdition("5e");
|
||||
const ids = conditions.map((d) => d.id);
|
||||
expect(ids).not.toContain("sapped");
|
||||
expect(ids).not.toContain("slowed");
|
||||
});
|
||||
|
||||
it("includes universal conditions for both editions", () => {
|
||||
const ids5e = getConditionsForEdition("5e").map((d) => d.id);
|
||||
const ids55e = getConditionsForEdition("5.5e").map((d) => d.id);
|
||||
expect(ids5e).toContain("blinded");
|
||||
expect(ids55e).toContain("blinded");
|
||||
});
|
||||
});
|
||||
@@ -12,15 +12,29 @@ export type ConditionId =
|
||||
| "poisoned"
|
||||
| "prone"
|
||||
| "restrained"
|
||||
| "sapped"
|
||||
| "slowed"
|
||||
| "stunned"
|
||||
| "unconscious";
|
||||
|
||||
export type RulesEdition = "5e" | "5.5e";
|
||||
|
||||
export interface ConditionDefinition {
|
||||
readonly id: ConditionId;
|
||||
readonly label: string;
|
||||
readonly description: string;
|
||||
readonly description5e: string;
|
||||
readonly iconName: string;
|
||||
readonly color: string;
|
||||
/** When set, the condition only appears in this edition's picker. */
|
||||
readonly edition?: RulesEdition;
|
||||
}
|
||||
|
||||
export function getConditionDescription(
|
||||
def: ConditionDefinition,
|
||||
edition: RulesEdition,
|
||||
): string {
|
||||
return edition === "5e" ? def.description5e : def.description;
|
||||
}
|
||||
|
||||
export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
@@ -29,6 +43,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
label: "Blinded",
|
||||
description:
|
||||
"Can't see. Auto-fail sight checks. Attacks have Disadvantage; attacks against have Advantage.",
|
||||
description5e:
|
||||
"Can't see. Auto-fail sight checks. Attacks have Disadvantage; attacks against have Advantage.",
|
||||
iconName: "EyeOff",
|
||||
color: "neutral",
|
||||
},
|
||||
@@ -37,6 +53,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
label: "Charmed",
|
||||
description:
|
||||
"Can't attack or target the charmer with harmful abilities. Charmer has Advantage on social checks.",
|
||||
description5e:
|
||||
"Can't attack or target the charmer with harmful abilities. Charmer has Advantage on social checks.",
|
||||
iconName: "Heart",
|
||||
color: "pink",
|
||||
},
|
||||
@@ -44,6 +62,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
id: "deafened",
|
||||
label: "Deafened",
|
||||
description: "Can't hear. Auto-fail hearing checks.",
|
||||
description5e: "Can't hear. Auto-fail hearing checks.",
|
||||
iconName: "EarOff",
|
||||
color: "neutral",
|
||||
},
|
||||
@@ -51,7 +70,9 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
id: "exhaustion",
|
||||
label: "Exhaustion",
|
||||
description:
|
||||
"Subtract exhaustion level from D20 Tests and Spell save DCs. Speed reduced by 5 ft. \u00D7 level. Removed by long rest (1 level) or death at 10 levels.",
|
||||
"D20 Tests reduced by 2 \u00D7 exhaustion level.\nSpeed reduced by 5 ft. \u00D7 level.\nLong rest removes 1 level.\nDeath at 6 levels.",
|
||||
description5e:
|
||||
"L1: Disadvantage on ability checks\nL2: Speed halved\nL3: Disadvantage on attacks and saves\nL4: HP max halved\nL5: Speed 0\nL6: Death\nLong rest removes 1 level.",
|
||||
iconName: "BatteryLow",
|
||||
color: "amber",
|
||||
},
|
||||
@@ -60,6 +81,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
label: "Frightened",
|
||||
description:
|
||||
"Disadvantage on ability checks and attacks while source of fear is in line of sight. Can't willingly move closer to the source.",
|
||||
description5e:
|
||||
"Disadvantage on ability checks and attacks while source of fear is in line of sight. Can't willingly move closer to the source.",
|
||||
iconName: "Siren",
|
||||
color: "orange",
|
||||
},
|
||||
@@ -67,7 +90,9 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
id: "grappled",
|
||||
label: "Grappled",
|
||||
description:
|
||||
"Speed is 0 and can't benefit from bonuses to speed. Ends if grappler is Incapacitated or moved out of reach.",
|
||||
"Speed 0. Disadvantage on attacks against targets other than the grappler. Grappler can drag you (extra movement cost). Ends if grappler is Incapacitated or you leave their reach.",
|
||||
description5e:
|
||||
"Speed 0. Ends if grappler is Incapacitated or moved out of reach.",
|
||||
iconName: "Hand",
|
||||
color: "neutral",
|
||||
},
|
||||
@@ -75,7 +100,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
id: "incapacitated",
|
||||
label: "Incapacitated",
|
||||
description:
|
||||
"Can't take Actions, Bonus Actions, or Reactions. Concentration is broken.",
|
||||
"Can't take Actions, Bonus Actions, or Reactions. Can't speak. Concentration is broken. Disadvantage on Initiative.",
|
||||
description5e: "Can't take Actions or Reactions.",
|
||||
iconName: "Ban",
|
||||
color: "gray",
|
||||
},
|
||||
@@ -83,6 +109,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
id: "invisible",
|
||||
label: "Invisible",
|
||||
description:
|
||||
"Can't be seen. Advantage on Initiative. Not affected by effects requiring sight (unless caster sees you). Attacks have Advantage; attacks against have Disadvantage.",
|
||||
description5e:
|
||||
"Impossible to see without magic or special sense. Heavily Obscured. Attacks have Advantage; attacks against have Disadvantage.",
|
||||
iconName: "Ghost",
|
||||
color: "violet",
|
||||
@@ -92,6 +120,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
label: "Paralyzed",
|
||||
description:
|
||||
"Incapacitated. Can't move or speak. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||
description5e:
|
||||
"Incapacitated. Can't move or speak. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||
iconName: "ZapOff",
|
||||
color: "yellow",
|
||||
},
|
||||
@@ -100,6 +130,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
label: "Petrified",
|
||||
description:
|
||||
"Turned to stone. Weight \u00D710. Incapacitated. Can't move or speak. Attacks against have Advantage. Auto-fail Str/Dex saves. Resistant to all damage. Immune to poison and disease.",
|
||||
description5e:
|
||||
"Turned to stone. Weight \u00D710. Incapacitated. Can't move or speak. Attacks against have Advantage. Auto-fail Str/Dex saves. Resistant to all damage. Immune to poison and disease.",
|
||||
iconName: "Gem",
|
||||
color: "slate",
|
||||
},
|
||||
@@ -107,6 +139,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
id: "poisoned",
|
||||
label: "Poisoned",
|
||||
description: "Disadvantage on attack rolls and ability checks.",
|
||||
description5e: "Disadvantage on attack rolls and ability checks.",
|
||||
iconName: "Droplet",
|
||||
color: "green",
|
||||
},
|
||||
@@ -115,6 +148,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
label: "Prone",
|
||||
description:
|
||||
"Can only crawl (costs extra movement). Disadvantage on attacks. Attacks within 5 ft. have Advantage; ranged attacks have Disadvantage. Standing up costs half movement.",
|
||||
description5e:
|
||||
"Can only crawl (costs extra movement). Disadvantage on attacks. Attacks within 5 ft. have Advantage; ranged attacks have Disadvantage. Standing up costs half movement.",
|
||||
iconName: "ArrowDown",
|
||||
color: "neutral",
|
||||
},
|
||||
@@ -123,13 +158,37 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
label: "Restrained",
|
||||
description:
|
||||
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
|
||||
description5e:
|
||||
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
|
||||
iconName: "Link",
|
||||
color: "neutral",
|
||||
},
|
||||
{
|
||||
id: "sapped",
|
||||
label: "Sapped",
|
||||
description:
|
||||
"Disadvantage on next attack roll before the start of your next turn. (Weapon Mastery: Sap)",
|
||||
description5e: "",
|
||||
iconName: "ShieldMinus",
|
||||
color: "amber",
|
||||
edition: "5.5e",
|
||||
},
|
||||
{
|
||||
id: "slowed",
|
||||
label: "Slowed",
|
||||
description:
|
||||
"Speed reduced by 10 ft. until the start of your next turn. (Weapon Mastery: Slow)",
|
||||
description5e: "",
|
||||
iconName: "Snail",
|
||||
color: "sky",
|
||||
edition: "5.5e",
|
||||
},
|
||||
{
|
||||
id: "stunned",
|
||||
label: "Stunned",
|
||||
description:
|
||||
"Incapacitated (can't act or speak). Auto-fail Str/Dex saves. Attacks against have Advantage.",
|
||||
description5e:
|
||||
"Incapacitated. Can't move. Can speak only falteringly. Auto-fail Str/Dex saves. Attacks against have Advantage.",
|
||||
iconName: "Sparkles",
|
||||
color: "yellow",
|
||||
@@ -138,7 +197,9 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
id: "unconscious",
|
||||
label: "Unconscious",
|
||||
description:
|
||||
"Incapacitated. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||
"Incapacitated. Speed 0. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||
description5e:
|
||||
"Incapacitated. Speed 0. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||
iconName: "Moon",
|
||||
color: "indigo",
|
||||
},
|
||||
@@ -147,3 +208,11 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
export const VALID_CONDITION_IDS: ReadonlySet<string> = new Set(
|
||||
CONDITION_DEFINITIONS.map((d) => d.id),
|
||||
);
|
||||
|
||||
export function getConditionsForEdition(
|
||||
edition: RulesEdition,
|
||||
): readonly ConditionDefinition[] {
|
||||
return CONDITION_DEFINITIONS.filter(
|
||||
(d) => d.edition === undefined || d.edition === edition,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ export {
|
||||
CONDITION_DEFINITIONS,
|
||||
type ConditionDefinition,
|
||||
type ConditionId,
|
||||
getConditionDescription,
|
||||
getConditionsForEdition,
|
||||
type RulesEdition,
|
||||
VALID_CONDITION_IDS,
|
||||
} from "./conditions.js";
|
||||
export {
|
||||
|
||||
@@ -229,12 +229,13 @@ Acceptance scenarios:
|
||||
2. **Given** the condition picker is open, **When** the user clicks an active condition, **Then** it is toggled off and removed from the row.
|
||||
3. **Given** a combatant with one condition and it is removed, **Then** only the hover-revealed "+" button remains.
|
||||
|
||||
**Story CC-3 — View Condition Name via Tooltip (P2)**
|
||||
As a DM, I want to hover over a condition icon to see its name so I can identify conditions without memorizing icons.
|
||||
**Story CC-3 — View Condition Details via Tooltip (P2)**
|
||||
As a DM, I want to hover over a condition icon to see its name and rules description so I can quickly reference the condition's effects during play.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** a combatant has an active condition, **When** the user hovers over its icon, **Then** a tooltip shows the condition name (e.g., "Blinded").
|
||||
1. **Given** a combatant has an active condition, **When** the user hovers over its icon, **Then** a tooltip shows the condition name and its rules description matching the selected edition.
|
||||
2. **Given** the user moves the cursor away from the icon, **Then** the tooltip disappears.
|
||||
3. **Given** the rules edition is set to 5e (2014), **When** the user hovers over "Exhaustion", **Then** the tooltip shows the 2014 exhaustion rules (6-level escalating table). **When** the edition is 5.5e (2024), **Then** the tooltip shows the 2024 rules (−2 per level to d20 tests, −5 ft speed per level).
|
||||
|
||||
**Story CC-4 — Multiple Conditions (P2)**
|
||||
As a DM, I want to apply multiple conditions to a single combatant so I can track complex combat situations.
|
||||
@@ -272,9 +273,21 @@ Acceptance scenarios:
|
||||
3. **Given** a combatant is NOT concentrating, **When** damage is taken, **Then** no pulse/flash occurs.
|
||||
4. **Given** a concentrating combatant takes damage, **When** the animation completes, **Then** the row returns to its normal concentration-active appearance.
|
||||
|
||||
**Story CC-8 — Rules Edition Setting (P2)**
|
||||
As a DM who plays in both 5e (2014) and 5.5e (2024) groups, I want to choose which edition's condition descriptions appear in tooltips so I reference the correct rules for the game I am running.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** the user opens the kebab menu, **When** they click "Settings", **Then** a settings modal opens.
|
||||
2. **Given** the settings modal is open, **When** viewing the Conditions section, **Then** a rules edition selector shows 5e (2014) and 5.5e (2024) with 5.5e selected by default.
|
||||
3. **Given** the user selects 5e (2014), **When** hovering a condition icon (e.g., Exhaustion), **Then** the tooltip shows the 2014 description.
|
||||
4. **Given** the user selects 5.5e (2024), **When** hovering the same condition, **Then** the tooltip shows the 2024 description.
|
||||
5. **Given** the user changes the edition and reloads the page, **Then** the selected edition is preserved.
|
||||
6. **Given** a condition with identical rules across editions (e.g., Deafened), **Then** the tooltip text is the same regardless of setting.
|
||||
7. **Given** the settings modal is open, **When** viewing the Theme section, **Then** a System / Light / Dark selector is available, replacing the inline cycle button in the kebab menu.
|
||||
|
||||
### Requirements
|
||||
|
||||
- **FR-032**: The MVP MUST support the following 15 standard D&D 5e conditions: blinded, charmed, deafened, exhaustion, frightened, grappled, incapacitated, invisible, paralyzed, petrified, poisoned, prone, restrained, stunned, unconscious.
|
||||
- **FR-032**: The MVP MUST support the following 15 standard D&D 5e/5.5e conditions: blinded, charmed, deafened, exhaustion, frightened, grappled, incapacitated, invisible, paralyzed, petrified, poisoned, prone, restrained, stunned, unconscious.
|
||||
- **FR-033**: Each condition MUST have a fixed icon and color mapping (Lucide icons; no emoji):
|
||||
|
||||
| Condition | Icon | Color |
|
||||
@@ -301,7 +314,7 @@ Acceptance scenarios:
|
||||
- **FR-037**: Clicking "+" MUST open the compact condition picker showing all conditions as icon + label pairs.
|
||||
- **FR-038**: Clicking a condition in the picker MUST toggle it on or off.
|
||||
- **FR-039**: Clicking an active condition icon tag in the row MUST remove that condition.
|
||||
- **FR-040**: Hovering an active condition icon MUST show a tooltip with the condition name.
|
||||
- **FR-040**: Hovering an active condition icon MUST show a tooltip with the condition name and its rules description for the selected edition.
|
||||
- **FR-041**: Condition icons MUST NOT increase the row's width; row height MAY increase to accommodate wrapping.
|
||||
- **FR-042**: The condition picker MUST close when the user clicks outside of it.
|
||||
- **FR-043**: Conditions MUST persist as part of combatant state (surviving page reload).
|
||||
@@ -327,6 +340,9 @@ Acceptance scenarios:
|
||||
- When concentration is toggled during an active pulse animation, the animation cancels and the new state applies immediately.
|
||||
- Multiple combatants may concentrate simultaneously; concentration is independent per combatant.
|
||||
- Conditions have no mechanical effects in the MVP baseline (no auto-disadvantage, no automation).
|
||||
- When the rules edition preference is missing from localStorage, the system defaults to 5.5e (2024).
|
||||
- Changing the edition while a condition tooltip is visible updates the tooltip on next hover (no live update required).
|
||||
- The settings modal is app-level UI; it does not interact with encounter state.
|
||||
|
||||
---
|
||||
|
||||
@@ -472,6 +488,14 @@ Acceptance scenarios:
|
||||
- **FR-092**: The "Initiative Tracker" heading MUST be removed to maximize vertical space for combatants.
|
||||
- **FR-093**: All interactive elements in the row MUST remain keyboard-accessible (focusable and operable via keyboard).
|
||||
- **FR-094**: On touch devices, hover-only controls ("+" button, "x" button) MUST remain accessible via tap or focus.
|
||||
- **FR-095**: The system MUST provide a settings modal accessible via a "Settings" item in the kebab overflow menu.
|
||||
- **FR-096**: The settings modal MUST include a Conditions section with a rules edition selector offering two options: 5e (2014) and 5.5e (2024).
|
||||
- **FR-097**: The default rules edition MUST be 5.5e (2024).
|
||||
- **FR-098**: Each condition definition MUST carry a description for both editions. Conditions with identical rules across editions MAY share a single description value.
|
||||
- **FR-099**: Condition tooltips MUST display the description corresponding to the active rules edition preference.
|
||||
- **FR-100**: The rules edition preference MUST persist across sessions via localStorage (key `"initiative:rules-edition"`).
|
||||
- **FR-101**: The settings modal MUST include a Theme section with System / Light / Dark options, replacing the inline theme cycle button in the kebab menu.
|
||||
- **FR-102**: The settings modal MUST close on Escape, click-outside, or the close button.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
@@ -515,3 +539,6 @@ Acceptance scenarios:
|
||||
- **SC-028**: The AC number is visually identifiable as armor class through the shield shape alone.
|
||||
- **SC-029**: No layout shift occurs when hovering/unhovering rows.
|
||||
- **SC-030**: All HP, AC, initiative, condition, and concentration interactions remain fully operable using only a keyboard.
|
||||
- **SC-031**: The user can switch rules edition in 2 interactions (open settings → select edition).
|
||||
- **SC-032**: Condition tooltips accurately reflect the selected edition's rules text for all conditions that differ between editions.
|
||||
- **SC-033**: The rules edition preference survives a full page reload.
|
||||
|
||||
Reference in New Issue
Block a user