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>
14 KiB
date, git_commit, branch, topic, tags, status
| date | git_commit | branch | topic | tags | status | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2026-03-24T10:22:04.341906+00:00 | cfd4aef724 |
main | Rules edition setting for condition tooltips + settings modal |
|
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, optionalclassName - Positioning: Uses
getBoundingClientRect()to place tooltip 4px above the trigger element, centered horizontally - Rendered via
createPortaltodocument.bodyat z-index 60 - Max width:
max-w-64(256px / 16rem) withtext-xs leading-snug - Text wraps naturally within the max-width constraint — no explicit truncation
- The tooltip accepts only
stringcontent, 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, optionaldisabledandkeepOpen - 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:
- Player Characters (Users icon) — calls
opts.onManagePlayers - Manage Sources (Library icon) — calls
opts.onOpenSourceManager - Import All Sources (Import icon) — conditional on bestiary loaded
- Theme cycle (Monitor/Sun/Moon icon) — calls
opts.onCycleTheme, useskeepOpen: true
- Player Characters (Users icon) — calls
- Theme constants at lines 219-229:
THEME_ICONSandTHEME_LABELSmaps - Line 293:
useThemeContext()providespreferenceandcycleTheme - 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:
currentPreferenceinitialized from localStorage on import (line 9) ThemePreferencetype:"system" | "light" | "dark"ResolvedThemetype:"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" viamatchMedia("(prefers-color-scheme: light)")(lines 29-38)applyTheme()— setsdocument.documentElement.dataset.theme(lines 40-42)- Uses
useSyncExternalStorefor 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:
ThemeProvidercallsuseTheme()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
openprop useEffectcallsdialog.showModal()/dialog.close()based onopen- 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):
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):
ThemeProviderEncounterProviderBestiaryProviderPlayerCharactersProviderBulkImportProviderSidePanelProviderInitiativeRollsProvider
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—ConditionDefinitioninterface (singledescriptionfield)packages/domain/src/conditions.ts:26-145—CONDITION_DEFINITIONSarray with current (mixed edition) descriptionsapps/web/src/components/condition-tags.tsx:75— Tooltip with${def.label}: ${def.description}apps/web/src/components/condition-picker.tsx:125— Tooltip withdef.descriptionapps/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 menuapps/web/src/components/action-bar.tsx:231-274—buildOverflowItems()(current menu items)apps/web/src/components/action-bar.tsx:293—useThemeContext()usage in ActionBarapps/web/src/hooks/use-theme.ts:1-98— Theme hook with localStorage,useSyncExternalStore, cycle/setapps/web/src/contexts/theme-context.tsx:1-19— Theme context providerapps/web/src/main.tsx:17-35— Provider nesting orderapps/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 patternapps/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
- 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.
- Domain type change: Should
ConditionDefinitioncarrydescription5eanddescription55efields, 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. - Settings context scope: Should a new
SettingsProvidermanage 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.