Files
initiative/docs/agents/research/2026-03-24-rules-edition-settings-modal.md
Lukas 4043612ccf
All checks were successful
CI / check (push) Successful in 1m8s
CI / build-image (push) Successful in 16s
Add rules edition setting for condition tooltips (5e/5.5e)
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>
2026-03-24 17:08:41 +01:00

257 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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.