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>
130 lines
3.7 KiB
TypeScript
130 lines
3.7 KiB
TypeScript
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>
|
|
);
|
|
}
|