Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4043612ccf | ||
|
|
cfd4aef724 |
@@ -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);
|
||||
@@ -62,6 +64,7 @@ export function App() {
|
||||
onManagePlayers={() =>
|
||||
playerCharacterRef.current?.openManagement()
|
||||
}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -90,6 +93,7 @@ export function App() {
|
||||
onManagePlayers={() =>
|
||||
playerCharacterRef.current?.openManagement()
|
||||
}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -120,6 +124,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 };
|
||||
}
|
||||
|
||||
@@ -6,10 +6,8 @@ import {
|
||||
Import,
|
||||
Library,
|
||||
Minus,
|
||||
Monitor,
|
||||
Moon,
|
||||
Plus,
|
||||
Sun,
|
||||
Settings,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import React, {
|
||||
@@ -25,7 +23,6 @@ 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 { useLongPress } from "../hooks/use-long-press.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { D20Icon } from "./d20-icon.js";
|
||||
@@ -44,6 +41,7 @@ interface ActionBarProps {
|
||||
inputRef?: RefObject<HTMLInputElement | null>;
|
||||
autoFocus?: boolean;
|
||||
onManagePlayers?: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
function creatureKey(r: SearchResult): string {
|
||||
@@ -216,26 +214,13 @@ function AddModeSuggestions({
|
||||
);
|
||||
}
|
||||
|
||||
const THEME_ICONS = {
|
||||
system: Monitor,
|
||||
light: Sun,
|
||||
dark: Moon,
|
||||
} as const;
|
||||
|
||||
const THEME_LABELS = {
|
||||
system: "Theme: System",
|
||||
light: "Theme: Light",
|
||||
dark: "Theme: Dark",
|
||||
} as const;
|
||||
|
||||
function buildOverflowItems(opts: {
|
||||
onManagePlayers?: () => void;
|
||||
onOpenSourceManager?: () => void;
|
||||
bestiaryLoaded: boolean;
|
||||
onBulkImport?: () => void;
|
||||
bulkImportDisabled?: boolean;
|
||||
themePreference?: "system" | "light" | "dark";
|
||||
onCycleTheme?: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
}): OverflowMenuItem[] {
|
||||
const items: OverflowMenuItem[] = [];
|
||||
if (opts.onManagePlayers) {
|
||||
@@ -260,14 +245,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,6 +259,7 @@ export function ActionBar({
|
||||
inputRef,
|
||||
autoFocus,
|
||||
onManagePlayers,
|
||||
onOpenSettings,
|
||||
}: Readonly<ActionBarProps>) {
|
||||
const {
|
||||
addCombatant,
|
||||
@@ -290,7 +273,6 @@ export function ActionBar({
|
||||
const { characters: playerCharacters } = usePlayerCharactersContext();
|
||||
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
||||
useSidePanelContext();
|
||||
const { preference: themePreference, cycleTheme } = useThemeContext();
|
||||
const { handleRollAllInitiative } = useInitiativeRollsContext();
|
||||
const { state: bulkImportState } = useBulkImportContext();
|
||||
|
||||
@@ -532,8 +514,7 @@ export function ActionBar({
|
||||
bestiaryLoaded,
|
||||
onBulkImport: showBulkImport,
|
||||
bulkImportDisabled: bulkImportState.status === "loading",
|
||||
themePreference,
|
||||
onCycleTheme: cycleTheme,
|
||||
onOpenSettings,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -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,
|
||||
@@ -19,6 +23,7 @@ import {
|
||||
} 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";
|
||||
|
||||
@@ -104,6 +109,7 @@ export function ConditionPicker({
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [onClose]);
|
||||
|
||||
const { edition } = useRulesEditionContext();
|
||||
const active = new Set(activeConditions ?? []);
|
||||
|
||||
return createPortal(
|
||||
@@ -122,7 +128,11 @@ export function ConditionPicker({
|
||||
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,
|
||||
@@ -18,6 +22,7 @@ import {
|
||||
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";
|
||||
|
||||
@@ -63,6 +68,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 +78,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}`}
|
||||
|
||||
129
apps/web/src/components/settings-modal.tsx
Normal file
129
apps/web/src/components/settings-modal.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
24
apps/web/src/contexts/rules-edition-context.tsx
Normal file
24
apps/web/src/contexts/rules-edition-context.tsx
Normal file
@@ -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;
|
||||
}
|
||||
52
apps/web/src/hooks/use-rules-edition.ts
Normal file
52
apps/web/src/hooks/use-rules-edition.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -71,8 +71,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 +86,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;
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
256
docs/agents/research/2026-03-24-rules-edition-settings-modal.md
Normal file
256
docs/agents/research/2026-03-24-rules-edition-settings-modal.md
Normal file
@@ -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.
|
||||
44
packages/domain/src/__tests__/conditions.test.ts
Normal file
44
packages/domain/src/__tests__/conditions.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CONDITION_DEFINITIONS,
|
||||
getConditionDescription,
|
||||
} 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("every condition has both descriptions", () => {
|
||||
for (const def of CONDITION_DEFINITIONS) {
|
||||
expect(def.description).toBeTruthy();
|
||||
expect(def.description5e).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -15,20 +15,32 @@ export type ConditionId =
|
||||
| "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;
|
||||
}
|
||||
|
||||
export function getConditionDescription(
|
||||
def: ConditionDefinition,
|
||||
edition: RulesEdition,
|
||||
): string {
|
||||
return edition === "5e" ? def.description5e : def.description;
|
||||
}
|
||||
|
||||
export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
{
|
||||
id: "blinded",
|
||||
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 +49,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 +58,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 +66,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 +77,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 +86,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 +96,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 +105,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 +116,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 +126,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 +135,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 +144,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,6 +154,8 @@ 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",
|
||||
},
|
||||
@@ -130,6 +163,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
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 +173,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",
|
||||
},
|
||||
|
||||
@@ -10,6 +10,8 @@ export {
|
||||
CONDITION_DEFINITIONS,
|
||||
type ConditionDefinition,
|
||||
type ConditionId,
|
||||
getConditionDescription,
|
||||
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