diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 6c953ec..8e3c87c 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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(null); const actionBarInputRef = useRef(null); const activeRowRef = useRef(null); @@ -62,6 +64,7 @@ export function App() { onManagePlayers={() => playerCharacterRef.current?.openManagement() } + onOpenSettings={() => setSettingsOpen(true)} autoFocus /> @@ -90,6 +93,7 @@ export function App() { onManagePlayers={() => playerCharacterRef.current?.openManagement() } + onOpenSettings={() => setSettingsOpen(true)} /> @@ -120,6 +124,10 @@ export function App() { /> )} + setSettingsOpen(false)} + /> ); diff --git a/apps/web/src/__tests__/test-providers.tsx b/apps/web/src/__tests__/test-providers.tsx index f9fc82f..719c010 100644 --- a/apps/web/src/__tests__/test-providers.tsx +++ b/apps/web/src/__tests__/test-providers.tsx @@ -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 ( - - - - - - {children} - - - - - + + + + + + + {children} + + + + + + ); } diff --git a/apps/web/src/components/__tests__/condition-picker.test.tsx b/apps/web/src/components/__tests__/condition-picker.test.tsx index 4b94731..bbe3863 100644 --- a/apps/web/src/components/__tests__/condition-picker.test.tsx +++ b/apps/web/src/components/__tests__/condition-picker.test.tsx @@ -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( - , + + + , ); return { ...result, onToggle, onClose }; } diff --git a/apps/web/src/components/action-bar.tsx b/apps/web/src/components/action-bar.tsx index 49777d2..f033464 100644 --- a/apps/web/src/components/action-bar.tsx +++ b/apps/web/src/components/action-bar.tsx @@ -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; 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: , - label: THEME_LABELS[pref], - onClick: opts.onCycleTheme, - keepOpen: true, + icon: , + label: "Settings", + onClick: opts.onOpenSettings, }); } return items; @@ -277,6 +259,7 @@ export function ActionBar({ inputRef, autoFocus, onManagePlayers, + onOpenSettings, }: Readonly) { 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 ( diff --git a/apps/web/src/components/condition-picker.tsx b/apps/web/src/components/condition-picker.tsx index 13cb28d..b85e427 100644 --- a/apps/web/src/components/condition-picker.tsx +++ b/apps/web/src/components/condition-picker.tsx @@ -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 ( - + + + +
+
+ + Conditions + +
+ {EDITION_OPTIONS.map((opt) => ( + + ))} +
+
+ +
+ + Theme + +
+ {THEME_OPTIONS.map((opt) => { + const Icon = opt.icon; + return ( + + ); + })} +
+
+
+ + ); +} diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index 6758417..11a45a6 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -43,7 +43,7 @@ export function Tooltip({ createPortal(
{content} diff --git a/apps/web/src/contexts/index.ts b/apps/web/src/contexts/index.ts index a6ffedb..bcb413e 100644 --- a/apps/web/src/contexts/index.ts +++ b/apps/web/src/contexts/index.ts @@ -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"; diff --git a/apps/web/src/contexts/rules-edition-context.tsx b/apps/web/src/contexts/rules-edition-context.tsx new file mode 100644 index 0000000..b4ef956 --- /dev/null +++ b/apps/web/src/contexts/rules-edition-context.tsx @@ -0,0 +1,24 @@ +import { createContext, type ReactNode, useContext } from "react"; +import { useRulesEdition } from "../hooks/use-rules-edition.js"; + +type RulesEditionContextValue = ReturnType; + +const RulesEditionContext = createContext( + null, +); + +export function RulesEditionProvider({ children }: { children: ReactNode }) { + const value = useRulesEdition(); + return ( + + {children} + + ); +} + +export function useRulesEditionContext(): RulesEditionContextValue { + const ctx = useContext(RulesEditionContext); + if (!ctx) + throw new Error("useRulesEditionContext requires RulesEditionProvider"); + return ctx; +} diff --git a/apps/web/src/hooks/use-rules-edition.ts b/apps/web/src/hooks/use-rules-edition.ts new file mode 100644 index 0000000..fa621a9 --- /dev/null +++ b/apps/web/src/hooks/use-rules-edition.ts @@ -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; +} diff --git a/apps/web/src/hooks/use-theme.ts b/apps/web/src/hooks/use-theme.ts index ce99866..5129c44 100644 --- a/apps/web/src/hooks/use-theme.ts +++ b/apps/web/src/hooks/use-theme.ts @@ -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; } diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 2872235..0c004fc 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -7,6 +7,7 @@ import { EncounterProvider, InitiativeRollsProvider, PlayerCharactersProvider, + RulesEditionProvider, SidePanelProvider, ThemeProvider, } from "./contexts/index.js"; @@ -17,19 +18,21 @@ if (root) { createRoot(root).render( - - - - - - - - - - - - - + + + + + + + + + + + + + + + , ); diff --git a/docs/agents/research/2026-03-24-rules-edition-settings-modal.md b/docs/agents/research/2026-03-24-rules-edition-settings-modal.md new file mode 100644 index 0000000..e28d90c --- /dev/null +++ b/docs/agents/research/2026-03-24-rules-edition-settings-modal.md @@ -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 `` patterns. The localStorage persistence pattern is well-established with a consistent `"initiative:"` 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 ``: + +**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 `` 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(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` — `` modal pattern (reference for settings modal) +- `apps/web/src/components/create-player-modal.tsx:106-191` — Form-based `` 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 + → .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. diff --git a/packages/domain/src/__tests__/conditions.test.ts b/packages/domain/src/__tests__/conditions.test.ts new file mode 100644 index 0000000..50ddb60 --- /dev/null +++ b/packages/domain/src/__tests__/conditions.test.ts @@ -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); + }); +}); diff --git a/packages/domain/src/conditions.ts b/packages/domain/src/conditions.ts index 555613d..e8c6050 100644 --- a/packages/domain/src/conditions.ts +++ b/packages/domain/src/conditions.ts @@ -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", }, diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index c48ab2d..4419192 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -10,6 +10,8 @@ export { CONDITION_DEFINITIONS, type ConditionDefinition, type ConditionId, + getConditionDescription, + type RulesEdition, VALID_CONDITION_IDS, } from "./conditions.js"; export { diff --git a/specs/003-combatant-state/spec.md b/specs/003-combatant-state/spec.md index b32e18b..aef6bab 100644 --- a/specs/003-combatant-state/spec.md +++ b/specs/003-combatant-state/spec.md @@ -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.