Add rules edition setting for condition tooltips (5e/5.5e)
All checks were successful
CI / check (push) Successful in 1m8s
CI / build-image (push) Successful in 16s

Introduce a settings modal (opened from the kebab menu) with a rules
edition selector for condition tooltip descriptions and a theme picker
replacing the inline cycle button. About half the conditions have
meaningful mechanical differences between editions.

- Add description5e field to ConditionDefinition with 5e (2014) text
- Add RulesEditionProvider context with localStorage persistence
- Create SettingsModal with Conditions and Theme sections
- Wire condition tooltips to edition-aware descriptions
- Fix 6 inaccurate 5.5e condition descriptions
- Update spec 003 with stories CC-3, CC-8 and FR-095–FR-102

Closes #12

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-24 17:08:41 +01:00
parent cfd4aef724
commit 4043612ccf
18 changed files with 663 additions and 82 deletions

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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 };
}

View File

@@ -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 (

View File

@@ -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(

View File

@@ -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}`}

View 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>
);
}

View File

@@ -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}

View File

@@ -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";

View 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;
}

View 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;
}

View File

@@ -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;
}

View File

@@ -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>,
);

View 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-095FR-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.

View 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);
});
});

View File

@@ -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",
},

View File

@@ -10,6 +10,8 @@ export {
CONDITION_DEFINITIONS,
type ConditionDefinition,
type ConditionId,
getConditionDescription,
type RulesEdition,
VALID_CONDITION_IDS,
} from "./conditions.js";
export {

View File

@@ -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.