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