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>
257 lines
14 KiB
Markdown
257 lines
14 KiB
Markdown
---
|
||
date: "2026-03-24T10:22:04.341906+00:00"
|
||
git_commit: cfd4aef724487a681e425cedfa08f3e89255f91a
|
||
branch: main
|
||
topic: "Rules edition setting for condition tooltips + settings modal"
|
||
tags: [research, codebase, conditions, settings, theme, modal, issue-12]
|
||
status: complete
|
||
---
|
||
|
||
# Research: Rules Edition Setting for Condition Tooltips + Settings Modal
|
||
|
||
## Research Question
|
||
|
||
Map the codebase for implementing issue #12: a rules edition setting (5e 2014 / 5.5e 2024) that controls condition tooltip descriptions, delivered via a new settings modal that also absorbs the existing theme toggle. Target spec: `specs/003-combatant-state/spec.md` (stories CC-3, CC-8, FR-095–FR-102).
|
||
|
||
## Summary
|
||
|
||
The implementation touches five areas: (1) the domain condition definitions, (2) the tooltip rendering in two web components, (3) the kebab overflow menu in the action bar, (4) the theme system (hook + context), and (5) a new settings modal following existing `<dialog>` patterns. The localStorage persistence pattern is well-established with a consistent `"initiative:<key>"` convention. The context provider tree in `main.tsx` is the integration point for a new settings context.
|
||
|
||
## Detailed Findings
|
||
|
||
### 1. Condition Definitions and Tooltip Data Flow
|
||
|
||
**Domain layer** — `packages/domain/src/conditions.ts`
|
||
|
||
The `ConditionDefinition` interface (line 18) carries a single `description: string` field. The `CONDITION_DEFINITIONS` array (line 26) holds all 15 conditions as `readonly` objects with `id`, `label`, `description`, `iconName`, and `color`. This is the single source of truth for condition data.
|
||
|
||
Exported types: `ConditionId` (union of 15 string literals), `ConditionDefinition`, `CONDITION_DEFINITIONS`, `VALID_CONDITION_IDS`.
|
||
|
||
**Web layer — condition-tags.tsx** (`apps/web/src/components/condition-tags.tsx`)
|
||
|
||
- Line 69: Looks up definition via `CONDITION_DEFINITIONS.find((d) => d.id === condId)`
|
||
- Line 75: Passes tooltip content as `` `${def.label}: ${def.description}` `` — combines label + description into a single string
|
||
- This is the tooltip shown when hovering active condition icons in the combatant row
|
||
|
||
**Web layer — condition-picker.tsx** (`apps/web/src/components/condition-picker.tsx`)
|
||
|
||
- Line 119: Iterates `CONDITION_DEFINITIONS.map(...)` directly
|
||
- Line 125: Passes `content={def.description}` to Tooltip — description only, no label prefix
|
||
- This is the tooltip shown when hovering conditions in the dropdown picker
|
||
|
||
**Key observation:** Both components read `def.description` directly from the imported domain constant. To make descriptions edition-aware, either (a) the domain type needs dual descriptions and consumers select by edition, or (b) a higher-level hook resolves the correct description before passing to components.
|
||
|
||
### 2. Tooltip Component
|
||
|
||
**File:** `apps/web/src/components/ui/tooltip.tsx`
|
||
|
||
- Props: `content: string`, `children: ReactNode`, optional `className`
|
||
- Positioning: Uses `getBoundingClientRect()` to place tooltip 4px above the trigger element, centered horizontally
|
||
- Rendered via `createPortal` to `document.body` at z-index 60
|
||
- Max width: `max-w-64` (256px / 16rem) with `text-xs leading-snug`
|
||
- Text wraps naturally within the max-width constraint — no explicit truncation
|
||
- The tooltip accepts only `string` content, not ReactNode
|
||
|
||
The current descriptions are short (1-2 sentences). The 5e (2014) exhaustion description will be longer (6-level table as text), which may benefit from the existing 256px wrapping. No changes to the tooltip component itself should be needed.
|
||
|
||
### 3. Kebab Menu (Overflow Menu)
|
||
|
||
**OverflowMenu component** — `apps/web/src/components/ui/overflow-menu.tsx`
|
||
|
||
- Generic menu component accepting `items: readonly OverflowMenuItem[]`
|
||
- Each item has: `icon: ReactNode`, `label: string`, `onClick: () => void`, optional `disabled` and `keepOpen`
|
||
- Opens upward (`bottom-full`) from the kebab button, right-aligned
|
||
- Close on click-outside (mousedown) and Escape key
|
||
|
||
**ActionBar integration** — `apps/web/src/components/action-bar.tsx`
|
||
|
||
- `buildOverflowItems()` function (line 231) constructs the menu items array
|
||
- Current items in order:
|
||
1. **Player Characters** (Users icon) — calls `opts.onManagePlayers`
|
||
2. **Manage Sources** (Library icon) — calls `opts.onOpenSourceManager`
|
||
3. **Import All Sources** (Import icon) — conditional on bestiary loaded
|
||
4. **Theme cycle** (Monitor/Sun/Moon icon) — calls `opts.onCycleTheme`, uses `keepOpen: true`
|
||
- Theme constants at lines 219-229: `THEME_ICONS` and `THEME_LABELS` maps
|
||
- Line 293: `useThemeContext()` provides `preference` and `cycleTheme`
|
||
- Line 529-537: Overflow items built with all options passed in
|
||
|
||
**To add a "Settings" item:** Add a new entry to `buildOverflowItems()` and remove the theme cycle entry. The new item would call a callback to open the settings modal.
|
||
|
||
### 4. Theme System
|
||
|
||
**Hook** — `apps/web/src/hooks/use-theme.ts`
|
||
|
||
- Module-level state: `currentPreference` initialized from localStorage on import (line 9)
|
||
- `ThemePreference` type: `"system" | "light" | "dark"`
|
||
- `ResolvedTheme` type: `"light" | "dark"`
|
||
- Storage key: `"initiative:theme"` (line 6)
|
||
- `loadPreference()` — reads localStorage, defaults to `"system"` (lines 11-19)
|
||
- `savePreference()` — writes to localStorage, silent on error (lines 21-27)
|
||
- `resolve()` — resolves "system" via `matchMedia("(prefers-color-scheme: light)")` (lines 29-38)
|
||
- `applyTheme()` — sets `document.documentElement.dataset.theme` (lines 40-42)
|
||
- Uses `useSyncExternalStore` for React integration (line 77)
|
||
- Exposes: `preference`, `resolved`, `setPreference`, `cycleTheme`
|
||
- OS preference change listener updates theme when preference is "system" (lines 54-63)
|
||
|
||
**Context** — `apps/web/src/contexts/theme-context.tsx`
|
||
|
||
- Simple wrapper: `ThemeProvider` calls `useTheme()` and provides via React context
|
||
- `useThemeContext()` hook for consumers (line 15)
|
||
|
||
**For settings modal:** The theme system already exposes `setPreference(pref)` which is exactly what the settings modal needs — direct selection instead of cycling.
|
||
|
||
### 5. localStorage Persistence Patterns
|
||
|
||
All storage follows a consistent pattern:
|
||
|
||
| Key | Content | Format |
|
||
|-----|---------|--------|
|
||
| `initiative:encounter` | Full encounter state | JSON object |
|
||
| `initiative:player-characters` | Player character array | JSON array |
|
||
| `initiative:theme` | Theme preference | Plain string |
|
||
|
||
**Common patterns:**
|
||
- Read: `try { localStorage.getItem(key) } catch { return default }`
|
||
- Write: `try { localStorage.setItem(key, value) } catch { /* silent */ }`
|
||
- Validation on read: type-check, range-check, reject invalid, return fallback
|
||
- Bootstrap: `useState(initializeFunction)` where initializer loads from storage
|
||
- Persistence: `useEffect([data], () => saveToStorage(data))`
|
||
|
||
**For rules edition:** Key would be `"initiative:rules-edition"`. Value would be a plain string (`"5e"` or `"5.5e"`), matching the theme pattern (simple string, not JSON). Default: `"5.5e"`.
|
||
|
||
### 6. Modal Patterns
|
||
|
||
Two modal implementations exist, both using HTML `<dialog>`:
|
||
|
||
**PlayerManagement** (`apps/web/src/components/player-management.tsx`)
|
||
- Controlled by `open` prop
|
||
- `useEffect` calls `dialog.showModal()` / `dialog.close()` based on `open`
|
||
- Cancel event (Escape) prevented and routed to `onClose`
|
||
- Backdrop click (mousedown on dialog element itself) routes to `onClose`
|
||
- Styling: `card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50`
|
||
- Header: title + X close button (ghost variant, muted foreground)
|
||
|
||
**CreatePlayerModal** (`apps/web/src/components/create-player-modal.tsx`)
|
||
- Same `<dialog>` pattern with identical open/close/cancel/backdrop handling
|
||
- Has form submission with validation and error display
|
||
- Same styling as PlayerManagement
|
||
|
||
**Shared dialog pattern (extract from both):**
|
||
```tsx
|
||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||
|
||
useEffect(() => {
|
||
const dialog = dialogRef.current;
|
||
if (!dialog) return;
|
||
if (open && !dialog.open) dialog.showModal();
|
||
else if (!open && dialog.open) dialog.close();
|
||
}, [open]);
|
||
|
||
useEffect(() => {
|
||
const dialog = dialogRef.current;
|
||
if (!dialog) return;
|
||
const handleCancel = (e: Event) => { e.preventDefault(); onClose(); };
|
||
const handleBackdropClick = (e: MouseEvent) => { if (e.target === dialog) onClose(); };
|
||
dialog.addEventListener("cancel", handleCancel);
|
||
dialog.addEventListener("mousedown", handleBackdropClick);
|
||
return () => { /* cleanup */ };
|
||
}, [onClose]);
|
||
```
|
||
|
||
### 7. Context Provider Tree
|
||
|
||
**File:** `apps/web/src/main.tsx`
|
||
|
||
Provider nesting order (outermost first):
|
||
1. `ThemeProvider`
|
||
2. `EncounterProvider`
|
||
3. `BestiaryProvider`
|
||
4. `PlayerCharactersProvider`
|
||
5. `BulkImportProvider`
|
||
6. `SidePanelProvider`
|
||
7. `InitiativeRollsProvider`
|
||
|
||
A new `SettingsProvider` (or `RulesEditionProvider`) would slot in early — before any component that reads condition descriptions. Since `ThemeProvider` is already the outermost, and the settings modal manages both theme and rules edition, one option is a `SettingsProvider` that wraps or replaces `ThemeProvider`.
|
||
|
||
### 8. 5e vs 5.5e Condition Text Differences
|
||
|
||
Based on research, here are the conditions with meaningful mechanical differences between editions. Conditions not listed are functionally identical across editions.
|
||
|
||
**Major changes:**
|
||
|
||
| Condition | 5e (2014) | 5.5e (2024) — current text |
|
||
|---|---|---|
|
||
| **Exhaustion** | 6 escalating levels: L1 disadvantage on ability checks, L2 speed halved, L3 disadvantage on attacks/saves, L4 HP max halved, L5 speed 0, L6 death | −level from d20 tests and spell save DCs. Speed reduced by 5 ft. × level. Death at 10 levels. (current) |
|
||
| **Grappled** | Speed 0. Ends if grappler incapacitated or moved out of reach. | Speed 0, can't benefit from speed bonuses. Ends if grappler incapacitated or moved out of reach. (current — but 2024 also adds disadvantage on attacks vs non-grappler) |
|
||
| **Invisible** | Can't be seen without magic/special sense. Heavily obscured. Advantage on attacks; disadvantage on attacks against. | 2024 broadened: can be gained from Hide action; grants Surprise (advantage on initiative), Concealed (unaffected by sight effects), attacks advantage/disadvantage. (current text is closer to 2014) |
|
||
| **Stunned** | Incapacitated. Can't move. Speak falteringly. Auto-fail Str/Dex saves. Attacks against have advantage. | 2024: same but can still move (controversial). (current text says "Can't move" — matches 2014) |
|
||
|
||
**Moderate changes:**
|
||
|
||
| Condition | 5e (2014) | 5.5e (2024) |
|
||
|---|---|---|
|
||
| **Incapacitated** | Can't take actions or reactions. | Can't take actions, bonus actions, or reactions. Speed 0. Auto-fail Str/Dex saves. Attacks against have advantage. Concentration broken. (current is partial 2024) |
|
||
| **Petrified** | Unaware of surroundings. | Aware of surroundings (2024 change). Current text doesn't mention awareness. |
|
||
| **Poisoned** | Disadvantage on attacks and ability checks. | Same, but 2024 consolidates disease into poisoned. |
|
||
|
||
**Minor/identical:**
|
||
|
||
Blinded, Charmed ("harmful" → "damaging"), Deafened, Frightened, Paralyzed, Prone, Restrained, Unconscious — functionally identical between editions.
|
||
|
||
**Note on current descriptions:** The existing `conditions.ts` descriptions are a mix — exhaustion is clearly 2024, but stunned says "Can't move" which matches 2014. A full audit of each description against both editions will be needed during implementation to ensure accuracy.
|
||
|
||
## Code References
|
||
|
||
- `packages/domain/src/conditions.ts:18-24` — `ConditionDefinition` interface (single `description` field)
|
||
- `packages/domain/src/conditions.ts:26-145` — `CONDITION_DEFINITIONS` array with current (mixed edition) descriptions
|
||
- `apps/web/src/components/condition-tags.tsx:75` — Tooltip with `${def.label}: ${def.description}`
|
||
- `apps/web/src/components/condition-picker.tsx:125` — Tooltip with `def.description`
|
||
- `apps/web/src/components/ui/tooltip.tsx:1-55` — Tooltip component (string content, 256px max-width)
|
||
- `apps/web/src/components/ui/overflow-menu.tsx:1-73` — Generic overflow menu
|
||
- `apps/web/src/components/action-bar.tsx:231-274` — `buildOverflowItems()` (current menu items)
|
||
- `apps/web/src/components/action-bar.tsx:293` — `useThemeContext()` usage in ActionBar
|
||
- `apps/web/src/hooks/use-theme.ts:1-98` — Theme hook with localStorage, `useSyncExternalStore`, cycle/set
|
||
- `apps/web/src/contexts/theme-context.tsx:1-19` — Theme context provider
|
||
- `apps/web/src/main.tsx:17-35` — Provider nesting order
|
||
- `apps/web/src/components/player-management.tsx:55-131` — `<dialog>` modal pattern (reference for settings modal)
|
||
- `apps/web/src/components/create-player-modal.tsx:106-191` — Form-based `<dialog>` modal pattern
|
||
- `apps/web/src/persistence/encounter-storage.ts` — localStorage persistence pattern (read/write/validate)
|
||
- `apps/web/src/persistence/player-character-storage.ts` — localStorage persistence pattern
|
||
|
||
## Architecture Documentation
|
||
|
||
### Data Flow: Condition Description → Tooltip
|
||
|
||
```
|
||
Domain: CONDITION_DEFINITIONS[].description (single string)
|
||
↓ imported by
|
||
Web: condition-tags.tsx → Tooltip content={`${label}: ${description}`}
|
||
Web: condition-picker.tsx → Tooltip content={description}
|
||
↓ rendered by
|
||
UI: tooltip.tsx → createPortal → fixed-position div (max-w-64)
|
||
```
|
||
|
||
### Settings/Preference Architecture
|
||
|
||
```
|
||
localStorage → use-theme.ts (useSyncExternalStore) → theme-context.tsx → consumers
|
||
localStorage → encounter-storage.ts → use-encounter.ts (useState) → encounter-context.tsx
|
||
localStorage → player-character-storage.ts → use-player-characters.ts (useState) → pc-context.tsx
|
||
```
|
||
|
||
### Modal Triggering Pattern
|
||
|
||
```
|
||
ActionBar overflow menu item click
|
||
→ callback prop (e.g., onManagePlayers)
|
||
→ App.tsx calls imperative handle (e.g., playerCharacterRef.current.openManagement())
|
||
→ Section component sets open state
|
||
→ <dialog>.showModal() via useEffect
|
||
```
|
||
|
||
## Open Questions
|
||
|
||
1. **Current description accuracy:** The existing descriptions are a mix of 2014 and 2024 text (e.g., exhaustion is 2024, stunned "Can't move" is 2014). Both sets of descriptions need careful authoring against official sources during implementation.
|
||
2. **Domain type change:** Should `ConditionDefinition` carry `description5e` and `description55e` fields, or should description resolution happen at the application/web layer? The domain-level approach is simpler and keeps the data co-located with condition definitions.
|
||
3. **Settings context scope:** Should a new `SettingsProvider` manage both rules edition and theme, or should rules edition be its own context? The theme system already has its own well-structured hook/context; combining them may add unnecessary coupling.
|