Add rules edition setting for condition tooltips (5e/5.5e)
All checks were successful
CI / check (push) Successful in 1m8s
CI / build-image (push) Successful in 16s

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>
This commit is contained in:
Lukas
2026-03-24 17:08:41 +01:00
parent cfd4aef724
commit 4043612ccf
18 changed files with 663 additions and 82 deletions

View File

@@ -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(
<ConditionPicker
anchorRef={anchorRef}
activeConditions={overrides.activeConditions ?? []}
onToggle={onToggle}
onClose={onClose}
/>,
<RulesEditionProvider>
<ConditionPicker
anchorRef={anchorRef}
activeConditions={overrides.activeConditions ?? []}
onToggle={onToggle}
onClose={onClose}
/>
</RulesEditionProvider>,
);
return { ...result, onToggle, onClose };
}

View File

@@ -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<HTMLInputElement | null>;
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: <ThemeIcon className="h-4 w-4" />,
label: THEME_LABELS[pref],
onClick: opts.onCycleTheme,
keepOpen: true,
icon: <Settings className="h-4 w-4" />,
label: "Settings",
onClick: opts.onOpenSettings,
});
}
return items;
@@ -277,6 +259,7 @@ export function ActionBar({
inputRef,
autoFocus,
onManagePlayers,
onOpenSettings,
}: Readonly<ActionBarProps>) {
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 (

View File

@@ -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 (
<Tooltip key={def.id} content={def.description} className="block">
<Tooltip
key={def.id}
content={getConditionDescription(def, edition)}
className="block"
>
<button
type="button"
className={cn(

View File

@@ -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,
@@ -18,6 +22,7 @@ import {
Sparkles,
ZapOff,
} from "lucide-react";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
import { cn } from "../lib/utils.js";
import { Tooltip } from "./ui/tooltip.js";
@@ -63,6 +68,7 @@ export function ConditionTags({
onRemove,
onOpenPicker,
}: Readonly<ConditionTagsProps>) {
const { edition } = useRulesEditionContext();
return (
<div className="flex flex-wrap items-center gap-0.5">
{conditions?.map((condId) => {
@@ -72,7 +78,10 @@ export function ConditionTags({
if (!Icon) return null;
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
return (
<Tooltip key={condId} content={`${def.label}: ${def.description}`}>
<Tooltip
key={condId}
content={`${def.label}:\n${getConditionDescription(def, edition)}`}
>
<button
type="button"
aria-label={`Remove ${def.label}`}

View File

@@ -0,0 +1,129 @@
import type { RulesEdition } from "@initiative/domain";
import { Monitor, Moon, Sun, X } from "lucide-react";
import { useEffect, useRef } from "react";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
import { useThemeContext } from "../contexts/theme-context.js";
import { cn } from "../lib/utils.js";
import { Button } from "./ui/button.js";
interface SettingsModalProps {
open: boolean;
onClose: () => void;
}
const EDITION_OPTIONS: { value: RulesEdition; label: string }[] = [
{ value: "5e", label: "5e (2014)" },
{ value: "5.5e", label: "5.5e (2024)" },
];
const THEME_OPTIONS: {
value: "system" | "light" | "dark";
label: string;
icon: typeof Sun;
}[] = [
{ value: "system", label: "System", icon: Monitor },
{ value: "light", label: "Light", icon: Sun },
{ value: "dark", label: "Dark", icon: Moon },
];
export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
const dialogRef = useRef<HTMLDialogElement>(null);
const { edition, setEdition } = useRulesEditionContext();
const { preference, setPreference } = useThemeContext();
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;
function handleCancel(e: Event) {
e.preventDefault();
onClose();
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === dialog) onClose();
}
dialog.addEventListener("cancel", handleCancel);
dialog.addEventListener("mousedown", handleBackdropClick);
return () => {
dialog.removeEventListener("cancel", handleCancel);
dialog.removeEventListener("mousedown", handleBackdropClick);
};
}, [onClose]);
return (
<dialog
ref={dialogRef}
className="card-glow m-auto w-full max-w-sm rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
>
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold text-foreground text-lg">Settings</h2>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="text-muted-foreground"
>
<X size={20} />
</Button>
</div>
<div className="flex flex-col gap-5">
<div>
<span className="mb-2 block font-medium text-muted-foreground text-sm">
Conditions
</span>
<div className="flex gap-1">
{EDITION_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={cn(
"flex-1 rounded-md px-3 py-1.5 text-sm transition-colors",
edition === opt.value
? "bg-accent text-primary-foreground"
: "bg-card text-muted-foreground hover:bg-hover-neutral-bg hover:text-foreground",
)}
onClick={() => setEdition(opt.value)}
>
{opt.label}
</button>
))}
</div>
</div>
<div>
<span className="mb-2 block font-medium text-muted-foreground text-sm">
Theme
</span>
<div className="flex gap-1">
{THEME_OPTIONS.map((opt) => {
const Icon = opt.icon;
return (
<button
key={opt.value}
type="button"
className={cn(
"flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm transition-colors",
preference === opt.value
? "bg-accent text-primary-foreground"
: "bg-card text-muted-foreground hover:bg-hover-neutral-bg hover:text-foreground",
)}
onClick={() => setPreference(opt.value)}
>
<Icon size={14} />
{opt.label}
</button>
);
})}
</div>
</div>
</div>
</dialog>
);
}

View File

@@ -43,7 +43,7 @@ export function Tooltip({
createPortal(
<div
role="tooltip"
className="pointer-events-none fixed z-[60] max-w-64 -translate-x-1/2 -translate-y-full rounded-md border border-border bg-background px-2.5 py-1.5 text-foreground text-xs leading-snug shadow-lg"
className="pointer-events-none fixed z-[60] max-w-64 -translate-x-1/2 -translate-y-full whitespace-pre-line rounded-md border border-border bg-background px-2.5 py-1.5 text-foreground text-xs leading-snug shadow-lg"
style={{ top: pos.top, left: pos.left }}
>
{content}