Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d0ec0c7b2 | ||
|
|
fe62f2eb2f | ||
|
|
7092677273 | ||
|
|
e1a06c9d59 | ||
|
|
4043612ccf | ||
|
|
cfd4aef724 |
@@ -3,6 +3,15 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#0e1a2e" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<meta property="og:title" content="Initiative Tracker" />
|
||||||
|
<meta property="og:description" content="D&D combat initiative tracker" />
|
||||||
|
<meta property="og:image" content="https://initiative.dostulata.rocks/icon-512.png" />
|
||||||
|
<meta property="og:url" content="https://initiative.dostulata.rocks/" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
<title>Initiative Tracker</title>
|
<title>Initiative Tracker</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
BIN
apps/web/public/apple-touch-icon.png
Normal file
BIN
apps/web/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
21
apps/web/public/favicon.svg
Normal file
21
apps/web/public/favicon.svg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="f" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#60a5fa"/>
|
||||||
|
<stop offset="100%" stop-color="#2563eb"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g transform="translate(16 15) scale(1.55)" fill="none" stroke="url(#f)" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76" fill="url(#f)" fill-opacity="0.15"/>
|
||||||
|
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49" fill="url(#f)" fill-opacity="0.25"/>
|
||||||
|
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44" fill="url(#f)" fill-opacity="0.2"/>
|
||||||
|
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44" fill="url(#f)" fill-opacity="0.1"/>
|
||||||
|
<line x1="-4.75" y1="-2.74" x2="-8.19" y2="-4.74"/>
|
||||||
|
<line x1="8.18" y1="-4.74" x2="4.75" y2="-2.74"/>
|
||||||
|
<line x1="-0.01" y1="5.44" x2="-0.01" y2="9.51"/>
|
||||||
|
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76"/>
|
||||||
|
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49"/>
|
||||||
|
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44"/>
|
||||||
|
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
apps/web/public/icon-192.png
Normal file
BIN
apps/web/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
apps/web/public/icon-512.png
Normal file
BIN
apps/web/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
31
apps/web/public/icon.svg
Normal file
31
apps/web/public/icon.svg
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg" cx="50%" cy="40%" r="70%">
|
||||||
|
<stop offset="0%" stop-color="#1a2e4a"/>
|
||||||
|
<stop offset="100%" stop-color="#0e1a2e"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="d20fill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#60a5fa"/>
|
||||||
|
<stop offset="100%" stop-color="#2563eb"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="d20stroke" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#93c5fd"/>
|
||||||
|
<stop offset="100%" stop-color="#3b82f6"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
|
||||||
|
<g transform="translate(256 256) scale(8.5)" fill="none" stroke="url(#d20stroke)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76" fill="url(#d20fill)" fill-opacity="0.15"/>
|
||||||
|
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49" fill="url(#d20fill)" fill-opacity="0.25"/>
|
||||||
|
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44" fill="url(#d20fill)" fill-opacity="0.2"/>
|
||||||
|
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44" fill="url(#d20fill)" fill-opacity="0.1"/>
|
||||||
|
<line x1="-4.75" y1="-2.74" x2="-8.19" y2="-4.74"/>
|
||||||
|
<line x1="8.18" y1="-4.74" x2="4.75" y2="-2.74"/>
|
||||||
|
<line x1="-0.01" y1="5.44" x2="-0.01" y2="9.51"/>
|
||||||
|
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76"/>
|
||||||
|
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49"/>
|
||||||
|
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44"/>
|
||||||
|
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44"/>
|
||||||
|
</g>
|
||||||
|
<text x="256" y="278" text-anchor="middle" dominant-baseline="central" font-family="Inter, system-ui, sans-serif" font-weight="700" font-size="52" fill="#93c5fd" letter-spacing="1">20</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
21
apps/web/public/manifest.json
Normal file
21
apps/web/public/manifest.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "Initiative Tracker",
|
||||||
|
"short_name": "Initiative",
|
||||||
|
"description": "D&D combat initiative tracker",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0e1a2e",
|
||||||
|
"theme_color": "#0e1a2e",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ActionBar } from "./components/action-bar.js";
|
import { ActionBar } from "./components/action-bar.js";
|
||||||
import { BulkImportToasts } from "./components/bulk-import-toasts.js";
|
import { BulkImportToasts } from "./components/bulk-import-toasts.js";
|
||||||
import { CombatantRow } from "./components/combatant-row.js";
|
import { CombatantRow } from "./components/combatant-row.js";
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
PlayerCharacterSection,
|
PlayerCharacterSection,
|
||||||
type PlayerCharacterSectionHandle,
|
type PlayerCharacterSectionHandle,
|
||||||
} from "./components/player-character-section.js";
|
} from "./components/player-character-section.js";
|
||||||
|
import { SettingsModal } from "./components/settings-modal.js";
|
||||||
import { StatBlockPanel } from "./components/stat-block-panel.js";
|
import { StatBlockPanel } from "./components/stat-block-panel.js";
|
||||||
import { Toast } from "./components/toast.js";
|
import { Toast } from "./components/toast.js";
|
||||||
import { TurnNavigation } from "./components/turn-navigation.js";
|
import { TurnNavigation } from "./components/turn-navigation.js";
|
||||||
@@ -23,6 +24,7 @@ export function App() {
|
|||||||
|
|
||||||
useAutoStatBlock();
|
useAutoStatBlock();
|
||||||
|
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
|
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
|
||||||
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
||||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -41,10 +43,10 @@ export function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh flex-col">
|
<div className="flex h-dvh flex-col">
|
||||||
<div className="relative mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-3 px-4">
|
<div className="relative mx-auto flex min-h-0 w-full flex-1 flex-col gap-3 sm:max-w-2xl sm:px-4">
|
||||||
{!!actionBarAnim.showTopBar && (
|
{!!actionBarAnim.showTopBar && (
|
||||||
<div
|
<div
|
||||||
className={cn("shrink-0 pt-8", actionBarAnim.topBarClass)}
|
className={cn("shrink-0 sm:pt-8", actionBarAnim.topBarClass)}
|
||||||
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
||||||
>
|
>
|
||||||
<TurnNavigation />
|
<TurnNavigation />
|
||||||
@@ -62,6 +64,7 @@ export function App() {
|
|||||||
onManagePlayers={() =>
|
onManagePlayers={() =>
|
||||||
playerCharacterRef.current?.openManagement()
|
playerCharacterRef.current?.openManagement()
|
||||||
}
|
}
|
||||||
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,7 +85,7 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn("shrink-0 pb-8", actionBarAnim.settlingClass)}
|
className={cn("shrink-0 sm:pb-8", actionBarAnim.settlingClass)}
|
||||||
onAnimationEnd={actionBarAnim.onSettleEnd}
|
onAnimationEnd={actionBarAnim.onSettleEnd}
|
||||||
>
|
>
|
||||||
<ActionBar
|
<ActionBar
|
||||||
@@ -90,6 +93,7 @@ export function App() {
|
|||||||
onManagePlayers={() =>
|
onManagePlayers={() =>
|
||||||
playerCharacterRef.current?.openManagement()
|
playerCharacterRef.current?.openManagement()
|
||||||
}
|
}
|
||||||
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -120,6 +124,10 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SettingsModal
|
||||||
|
open={settingsOpen}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
/>
|
||||||
<PlayerCharacterSection ref={playerCharacterRef} />
|
<PlayerCharacterSection ref={playerCharacterRef} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
EncounterProvider,
|
EncounterProvider,
|
||||||
InitiativeRollsProvider,
|
InitiativeRollsProvider,
|
||||||
PlayerCharactersProvider,
|
PlayerCharactersProvider,
|
||||||
|
RulesEditionProvider,
|
||||||
SidePanelProvider,
|
SidePanelProvider,
|
||||||
ThemeProvider,
|
ThemeProvider,
|
||||||
} from "../contexts/index.js";
|
} from "../contexts/index.js";
|
||||||
@@ -12,17 +13,19 @@ import {
|
|||||||
export function AllProviders({ children }: { children: ReactNode }) {
|
export function AllProviders({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<EncounterProvider>
|
<RulesEditionProvider>
|
||||||
<BestiaryProvider>
|
<EncounterProvider>
|
||||||
<PlayerCharactersProvider>
|
<BestiaryProvider>
|
||||||
<BulkImportProvider>
|
<PlayerCharactersProvider>
|
||||||
<SidePanelProvider>
|
<BulkImportProvider>
|
||||||
<InitiativeRollsProvider>{children}</InitiativeRollsProvider>
|
<SidePanelProvider>
|
||||||
</SidePanelProvider>
|
<InitiativeRollsProvider>{children}</InitiativeRollsProvider>
|
||||||
</BulkImportProvider>
|
</SidePanelProvider>
|
||||||
</PlayerCharactersProvider>
|
</BulkImportProvider>
|
||||||
</BestiaryProvider>
|
</PlayerCharactersProvider>
|
||||||
</EncounterProvider>
|
</BestiaryProvider>
|
||||||
|
</EncounterProvider>
|
||||||
|
</RulesEditionProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,6 +300,44 @@ describe("normalizeBestiary", () => {
|
|||||||
expect(creatures[0].proficiencyBonus).toBe(6);
|
expect(creatures[0].proficiencyBonus).toBe(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes pre-2024 {@atk mw} tags to full attack type text", () => {
|
||||||
|
const raw = {
|
||||||
|
monster: [
|
||||||
|
{
|
||||||
|
name: "Adult Black Dragon",
|
||||||
|
source: "MM",
|
||||||
|
size: ["H"],
|
||||||
|
type: "dragon",
|
||||||
|
ac: [19],
|
||||||
|
hp: { average: 195, formula: "17d12 + 85" },
|
||||||
|
speed: { walk: 40, fly: 80, swim: 40 },
|
||||||
|
str: 23,
|
||||||
|
dex: 14,
|
||||||
|
con: 21,
|
||||||
|
int: 14,
|
||||||
|
wis: 13,
|
||||||
|
cha: 17,
|
||||||
|
passive: 21,
|
||||||
|
cr: "14",
|
||||||
|
action: [
|
||||||
|
{
|
||||||
|
name: "Bite",
|
||||||
|
entries: [
|
||||||
|
"{@atk mw} {@hit 11} to hit, reach 10 ft., one target. {@h}17 ({@damage 2d10 + 6}) piercing damage plus 4 ({@damage 1d8}) acid damage.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const creatures = normalizeBestiary(raw);
|
||||||
|
const bite = creatures[0].actions?.[0];
|
||||||
|
expect(bite?.text).toContain("Melee Weapon Attack:");
|
||||||
|
expect(bite?.text).not.toContain("mw");
|
||||||
|
expect(bite?.text).not.toContain("{@");
|
||||||
|
});
|
||||||
|
|
||||||
it("handles fly speed with hover condition", () => {
|
it("handles fly speed with hover condition", () => {
|
||||||
const raw = {
|
const raw = {
|
||||||
monster: [
|
monster: [
|
||||||
|
|||||||
@@ -50,6 +50,26 @@ describe("stripTags", () => {
|
|||||||
expect(stripTags("{@atkr m,r}")).toBe("Melee or Ranged Attack Roll:");
|
expect(stripTags("{@atkr m,r}")).toBe("Melee or Ranged Attack Roll:");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("strips {@atk mw} to Melee Weapon Attack:", () => {
|
||||||
|
expect(stripTags("{@atk mw}")).toBe("Melee Weapon Attack:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@atk rw} to Ranged Weapon Attack:", () => {
|
||||||
|
expect(stripTags("{@atk rw}")).toBe("Ranged Weapon Attack:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@atk ms} to Melee Spell Attack:", () => {
|
||||||
|
expect(stripTags("{@atk ms}")).toBe("Melee Spell Attack:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@atk rs} to Ranged Spell Attack:", () => {
|
||||||
|
expect(stripTags("{@atk rs}")).toBe("Ranged Spell Attack:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@atk mw,rw} to Melee or Ranged Weapon Attack:", () => {
|
||||||
|
expect(stripTags("{@atk mw,rw}")).toBe("Melee or Ranged Weapon Attack:");
|
||||||
|
});
|
||||||
|
|
||||||
it("strips {@recharge 5} to (Recharge 5-6)", () => {
|
it("strips {@recharge 5} to (Recharge 5-6)", () => {
|
||||||
expect(stripTags("{@recharge 5}")).toBe("(Recharge 5-6)");
|
expect(stripTags("{@recharge 5}")).toBe("(Recharge 5-6)");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { type IDBPDatabase, openDB } from "idb";
|
|||||||
|
|
||||||
const DB_NAME = "initiative-bestiary";
|
const DB_NAME = "initiative-bestiary";
|
||||||
const STORE_NAME = "sources";
|
const STORE_NAME = "sources";
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 2;
|
||||||
|
|
||||||
export interface CachedSourceInfo {
|
export interface CachedSourceInfo {
|
||||||
readonly sourceCode: string;
|
readonly sourceCode: string;
|
||||||
@@ -32,12 +32,16 @@ async function getDb(): Promise<IDBPDatabase | null> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
db = await openDB(DB_NAME, DB_VERSION, {
|
db = await openDB(DB_NAME, DB_VERSION, {
|
||||||
upgrade(database) {
|
upgrade(database, oldVersion, _newVersion, transaction) {
|
||||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
if (oldVersion < 1) {
|
||||||
database.createObjectStore(STORE_NAME, {
|
database.createObjectStore(STORE_NAME, {
|
||||||
keyPath: "sourceCode",
|
keyPath: "sourceCode",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 2 && database.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
// Clear cached creatures to pick up improved tag processing
|
||||||
|
transaction.objectStore(STORE_NAME).clear();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return db;
|
return db;
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ const ATKR_MAP: Record<string, string> = {
|
|||||||
"r,m": "Melee or Ranged Attack Roll:",
|
"r,m": "Melee or Ranged Attack Roll:",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ATK_MAP: Record<string, string> = {
|
||||||
|
mw: "Melee Weapon Attack:",
|
||||||
|
rw: "Ranged Weapon Attack:",
|
||||||
|
ms: "Melee Spell Attack:",
|
||||||
|
rs: "Ranged Spell Attack:",
|
||||||
|
"mw,rw": "Melee or Ranged Weapon Attack:",
|
||||||
|
"rw,mw": "Melee or Ranged Weapon Attack:",
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strips 5etools {@tag ...} markup from text, converting to plain readable text.
|
* Strips 5etools {@tag ...} markup from text, converting to plain readable text.
|
||||||
*
|
*
|
||||||
@@ -51,11 +60,16 @@ export function stripTags(text: string): string {
|
|||||||
// {@hit N} → "+N"
|
// {@hit N} → "+N"
|
||||||
result = result.replaceAll(/\{@hit\s+(\d+)\}/g, "+$1");
|
result = result.replaceAll(/\{@hit\s+(\d+)\}/g, "+$1");
|
||||||
|
|
||||||
// {@atkr type} → mapped attack roll text
|
// {@atkr type} → mapped attack roll text (2024 rules)
|
||||||
result = result.replaceAll(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
|
result = result.replaceAll(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
|
||||||
return ATKR_MAP[type.trim()] ?? "Attack Roll:";
|
return ATKR_MAP[type.trim()] ?? "Attack Roll:";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// {@atk type} → mapped attack type text (pre-2024 data)
|
||||||
|
result = result.replaceAll(/\{@atk\s+([^}]+)\}/g, (_, type: string) => {
|
||||||
|
return ATK_MAP[type.trim()] ?? "Attack:";
|
||||||
|
});
|
||||||
|
|
||||||
// {@actSave ability} → "Ability saving throw"
|
// {@actSave ability} → "Ability saving throw"
|
||||||
result = result.replaceAll(
|
result = result.replaceAll(
|
||||||
/\{@actSave\s+([^}]+)\}/g,
|
/\{@actSave\s+([^}]+)\}/g,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { cleanup, render, screen } from "@testing-library/react";
|
|||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { createRef, type RefObject } from "react";
|
import { createRef, type RefObject } from "react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { RulesEditionProvider } from "../../contexts/index.js";
|
||||||
import { ConditionPicker } from "../condition-picker";
|
import { ConditionPicker } from "../condition-picker";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
@@ -24,12 +25,14 @@ function renderPicker(
|
|||||||
document.body.appendChild(anchor);
|
document.body.appendChild(anchor);
|
||||||
(anchorRef as { current: HTMLElement }).current = anchor;
|
(anchorRef as { current: HTMLElement }).current = anchor;
|
||||||
const result = render(
|
const result = render(
|
||||||
<ConditionPicker
|
<RulesEditionProvider>
|
||||||
anchorRef={anchorRef}
|
<ConditionPicker
|
||||||
activeConditions={overrides.activeConditions ?? []}
|
anchorRef={anchorRef}
|
||||||
onToggle={onToggle}
|
activeConditions={overrides.activeConditions ?? []}
|
||||||
onClose={onClose}
|
onToggle={onToggle}
|
||||||
/>,
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
</RulesEditionProvider>,
|
||||||
);
|
);
|
||||||
return { ...result, onToggle, onClose };
|
return { ...result, onToggle, onClose };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,8 @@ import {
|
|||||||
Import,
|
Import,
|
||||||
Library,
|
Library,
|
||||||
Minus,
|
Minus,
|
||||||
Monitor,
|
|
||||||
Moon,
|
|
||||||
Plus,
|
Plus,
|
||||||
Sun,
|
Settings,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import React, {
|
import React, {
|
||||||
@@ -25,7 +23,6 @@ import { useEncounterContext } from "../contexts/encounter-context.js";
|
|||||||
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
||||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||||
import { useSidePanelContext } from "../contexts/side-panel-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 { useLongPress } from "../hooks/use-long-press.js";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
import { D20Icon } from "./d20-icon.js";
|
import { D20Icon } from "./d20-icon.js";
|
||||||
@@ -44,6 +41,7 @@ interface ActionBarProps {
|
|||||||
inputRef?: RefObject<HTMLInputElement | null>;
|
inputRef?: RefObject<HTMLInputElement | null>;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
onManagePlayers?: () => void;
|
onManagePlayers?: () => void;
|
||||||
|
onOpenSettings?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function creatureKey(r: SearchResult): string {
|
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: {
|
function buildOverflowItems(opts: {
|
||||||
onManagePlayers?: () => void;
|
onManagePlayers?: () => void;
|
||||||
onOpenSourceManager?: () => void;
|
onOpenSourceManager?: () => void;
|
||||||
bestiaryLoaded: boolean;
|
bestiaryLoaded: boolean;
|
||||||
onBulkImport?: () => void;
|
onBulkImport?: () => void;
|
||||||
bulkImportDisabled?: boolean;
|
bulkImportDisabled?: boolean;
|
||||||
themePreference?: "system" | "light" | "dark";
|
onOpenSettings?: () => void;
|
||||||
onCycleTheme?: () => void;
|
|
||||||
}): OverflowMenuItem[] {
|
}): OverflowMenuItem[] {
|
||||||
const items: OverflowMenuItem[] = [];
|
const items: OverflowMenuItem[] = [];
|
||||||
if (opts.onManagePlayers) {
|
if (opts.onManagePlayers) {
|
||||||
@@ -260,14 +245,11 @@ function buildOverflowItems(opts: {
|
|||||||
disabled: opts.bulkImportDisabled,
|
disabled: opts.bulkImportDisabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (opts.onCycleTheme) {
|
if (opts.onOpenSettings) {
|
||||||
const pref = opts.themePreference ?? "system";
|
|
||||||
const ThemeIcon = THEME_ICONS[pref];
|
|
||||||
items.push({
|
items.push({
|
||||||
icon: <ThemeIcon className="h-4 w-4" />,
|
icon: <Settings className="h-4 w-4" />,
|
||||||
label: THEME_LABELS[pref],
|
label: "Settings",
|
||||||
onClick: opts.onCycleTheme,
|
onClick: opts.onOpenSettings,
|
||||||
keepOpen: true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
@@ -277,6 +259,7 @@ export function ActionBar({
|
|||||||
inputRef,
|
inputRef,
|
||||||
autoFocus,
|
autoFocus,
|
||||||
onManagePlayers,
|
onManagePlayers,
|
||||||
|
onOpenSettings,
|
||||||
}: Readonly<ActionBarProps>) {
|
}: Readonly<ActionBarProps>) {
|
||||||
const {
|
const {
|
||||||
addCombatant,
|
addCombatant,
|
||||||
@@ -290,7 +273,6 @@ export function ActionBar({
|
|||||||
const { characters: playerCharacters } = usePlayerCharactersContext();
|
const { characters: playerCharacters } = usePlayerCharactersContext();
|
||||||
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
||||||
useSidePanelContext();
|
useSidePanelContext();
|
||||||
const { preference: themePreference, cycleTheme } = useThemeContext();
|
|
||||||
const { handleRollAllInitiative } = useInitiativeRollsContext();
|
const { handleRollAllInitiative } = useInitiativeRollsContext();
|
||||||
const { state: bulkImportState } = useBulkImportContext();
|
const { state: bulkImportState } = useBulkImportContext();
|
||||||
|
|
||||||
@@ -532,12 +514,11 @@ export function ActionBar({
|
|||||||
bestiaryLoaded,
|
bestiaryLoaded,
|
||||||
onBulkImport: showBulkImport,
|
onBulkImport: showBulkImport,
|
||||||
bulkImportDisabled: bulkImportState.status === "loading",
|
bulkImportDisabled: bulkImportState.status === "loading",
|
||||||
themePreference,
|
onOpenSettings,
|
||||||
onCycleTheme: cycleTheme,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
|
<div className="card-glow flex items-center gap-3 border-border border-t bg-card px-4 py-3 sm:rounded-lg sm:border">
|
||||||
<form
|
<form
|
||||||
onSubmit={handleAdd}
|
onSubmit={handleAdd}
|
||||||
className="relative flex flex-1 items-center gap-2"
|
className="relative flex flex-1 items-center gap-2"
|
||||||
|
|||||||
@@ -520,7 +520,7 @@ export function CombatantRow({
|
|||||||
isPulsing && "animate-concentration-pulse",
|
isPulsing && "animate-concentration-pulse",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem] items-center gap-3 py-2">
|
<div className="grid grid-cols-[2rem_3rem_auto_1fr_auto_2rem] items-center gap-1.5 py-2 sm:grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem] sm:gap-3">
|
||||||
{/* Concentration */}
|
{/* Concentration */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -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 type { LucideIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
@@ -19,6 +23,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { Tooltip } from "./ui/tooltip.js";
|
import { Tooltip } from "./ui/tooltip.js";
|
||||||
|
|
||||||
@@ -104,6 +109,7 @@ export function ConditionPicker({
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
const active = new Set(activeConditions ?? []);
|
const active = new Set(activeConditions ?? []);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
@@ -122,7 +128,11 @@ export function ConditionPicker({
|
|||||||
const isActive = active.has(def.id);
|
const isActive = active.has(def.id);
|
||||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||||
return (
|
return (
|
||||||
<Tooltip key={def.id} content={def.description} className="block">
|
<Tooltip
|
||||||
|
key={def.id}
|
||||||
|
content={getConditionDescription(def, edition)}
|
||||||
|
className="block"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -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 type { LucideIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
@@ -18,6 +22,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
ZapOff,
|
ZapOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
import { Tooltip } from "./ui/tooltip.js";
|
import { Tooltip } from "./ui/tooltip.js";
|
||||||
|
|
||||||
@@ -63,6 +68,7 @@ export function ConditionTags({
|
|||||||
onRemove,
|
onRemove,
|
||||||
onOpenPicker,
|
onOpenPicker,
|
||||||
}: Readonly<ConditionTagsProps>) {
|
}: Readonly<ConditionTagsProps>) {
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-0.5">
|
<div className="flex flex-wrap items-center gap-0.5">
|
||||||
{conditions?.map((condId) => {
|
{conditions?.map((condId) => {
|
||||||
@@ -72,7 +78,10 @@ export function ConditionTags({
|
|||||||
if (!Icon) return null;
|
if (!Icon) return null;
|
||||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||||
return (
|
return (
|
||||||
<Tooltip key={condId} content={`${def.label}: ${def.description}`}>
|
<Tooltip
|
||||||
|
key={condId}
|
||||||
|
content={`${def.label}:\n${getConditionDescription(def, edition)}`}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Remove ${def.label}`}
|
aria-label={`Remove ${def.label}`}
|
||||||
|
|||||||
129
apps/web/src/components/settings-modal.tsx
Normal file
129
apps/web/src/components/settings-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ export function TurnNavigation() {
|
|||||||
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
|
<div className="card-glow flex items-center gap-3 border-border border-b bg-card px-4 py-3 sm:rounded-lg sm:border">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function Tooltip({
|
|||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
role="tooltip"
|
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 }}
|
style={{ top: pos.top, left: pos.left }}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ export { BulkImportProvider } from "./bulk-import-context.js";
|
|||||||
export { EncounterProvider } from "./encounter-context.js";
|
export { EncounterProvider } from "./encounter-context.js";
|
||||||
export { InitiativeRollsProvider } from "./initiative-rolls-context.js";
|
export { InitiativeRollsProvider } from "./initiative-rolls-context.js";
|
||||||
export { PlayerCharactersProvider } from "./player-characters-context.js";
|
export { PlayerCharactersProvider } from "./player-characters-context.js";
|
||||||
|
export { RulesEditionProvider } from "./rules-edition-context.js";
|
||||||
export { SidePanelProvider } from "./side-panel-context.js";
|
export { SidePanelProvider } from "./side-panel-context.js";
|
||||||
export { ThemeProvider } from "./theme-context.js";
|
export { ThemeProvider } from "./theme-context.js";
|
||||||
|
|||||||
24
apps/web/src/contexts/rules-edition-context.tsx
Normal file
24
apps/web/src/contexts/rules-edition-context.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { createContext, type ReactNode, useContext } from "react";
|
||||||
|
import { useRulesEdition } from "../hooks/use-rules-edition.js";
|
||||||
|
|
||||||
|
type RulesEditionContextValue = ReturnType<typeof useRulesEdition>;
|
||||||
|
|
||||||
|
const RulesEditionContext = createContext<RulesEditionContextValue | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
export function RulesEditionProvider({ children }: { children: ReactNode }) {
|
||||||
|
const value = useRulesEdition();
|
||||||
|
return (
|
||||||
|
<RulesEditionContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</RulesEditionContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRulesEditionContext(): RulesEditionContextValue {
|
||||||
|
const ctx = useContext(RulesEditionContext);
|
||||||
|
if (!ctx)
|
||||||
|
throw new Error("useRulesEditionContext requires RulesEditionProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
52
apps/web/src/hooks/use-rules-edition.ts
Normal file
52
apps/web/src/hooks/use-rules-edition.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { RulesEdition } from "@initiative/domain";
|
||||||
|
import { useCallback, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "initiative:rules-edition";
|
||||||
|
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
let currentEdition: RulesEdition = loadEdition();
|
||||||
|
|
||||||
|
function loadEdition(): RulesEdition {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw === "5e" || raw === "5.5e") return raw;
|
||||||
|
} catch {
|
||||||
|
// storage unavailable
|
||||||
|
}
|
||||||
|
return "5.5e";
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveEdition(edition: RulesEdition): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, edition);
|
||||||
|
} catch {
|
||||||
|
// quota exceeded or storage unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyAll(): void {
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(callback: () => void): () => void {
|
||||||
|
listeners.add(callback);
|
||||||
|
return () => listeners.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshot(): RulesEdition {
|
||||||
|
return currentEdition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRulesEdition() {
|
||||||
|
const edition = useSyncExternalStore(subscribe, getSnapshot);
|
||||||
|
|
||||||
|
const setEdition = useCallback((next: RulesEdition) => {
|
||||||
|
currentEdition = next;
|
||||||
|
saveEdition(next);
|
||||||
|
notifyAll();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { edition, setEdition } as const;
|
||||||
|
}
|
||||||
@@ -71,8 +71,6 @@ function getSnapshot(): ThemePreference {
|
|||||||
return currentPreference;
|
return currentPreference;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CYCLE: ThemePreference[] = ["system", "light", "dark"];
|
|
||||||
|
|
||||||
export function useTheme() {
|
export function useTheme() {
|
||||||
const preference = useSyncExternalStore(subscribe, getSnapshot);
|
const preference = useSyncExternalStore(subscribe, getSnapshot);
|
||||||
const resolved = resolve(preference);
|
const resolved = resolve(preference);
|
||||||
@@ -88,11 +86,5 @@ export function useTheme() {
|
|||||||
notifyAll();
|
notifyAll();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const cycleTheme = useCallback(() => {
|
return { preference, resolved, setPreference } as const;
|
||||||
const idx = CYCLE.indexOf(currentPreference);
|
|
||||||
const next = CYCLE[(idx + 1) % CYCLE.length];
|
|
||||||
setPreference(next);
|
|
||||||
}, [setPreference]);
|
|
||||||
|
|
||||||
return { preference, resolved, setPreference, cycleTheme } as const;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
EncounterProvider,
|
EncounterProvider,
|
||||||
InitiativeRollsProvider,
|
InitiativeRollsProvider,
|
||||||
PlayerCharactersProvider,
|
PlayerCharactersProvider,
|
||||||
|
RulesEditionProvider,
|
||||||
SidePanelProvider,
|
SidePanelProvider,
|
||||||
ThemeProvider,
|
ThemeProvider,
|
||||||
} from "./contexts/index.js";
|
} from "./contexts/index.js";
|
||||||
@@ -17,19 +18,21 @@ if (root) {
|
|||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<EncounterProvider>
|
<RulesEditionProvider>
|
||||||
<BestiaryProvider>
|
<EncounterProvider>
|
||||||
<PlayerCharactersProvider>
|
<BestiaryProvider>
|
||||||
<BulkImportProvider>
|
<PlayerCharactersProvider>
|
||||||
<SidePanelProvider>
|
<BulkImportProvider>
|
||||||
<InitiativeRollsProvider>
|
<SidePanelProvider>
|
||||||
<App />
|
<InitiativeRollsProvider>
|
||||||
</InitiativeRollsProvider>
|
<App />
|
||||||
</SidePanelProvider>
|
</InitiativeRollsProvider>
|
||||||
</BulkImportProvider>
|
</SidePanelProvider>
|
||||||
</PlayerCharactersProvider>
|
</BulkImportProvider>
|
||||||
</BestiaryProvider>
|
</PlayerCharactersProvider>
|
||||||
</EncounterProvider>
|
</BestiaryProvider>
|
||||||
|
</EncounterProvider>
|
||||||
|
</RulesEditionProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
256
docs/agents/research/2026-03-24-rules-edition-settings-modal.md
Normal file
256
docs/agents/research/2026-03-24-rules-edition-settings-modal.md
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
44
packages/domain/src/__tests__/conditions.test.ts
Normal file
44
packages/domain/src/__tests__/conditions.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
CONDITION_DEFINITIONS,
|
||||||
|
getConditionDescription,
|
||||||
|
} from "../conditions.js";
|
||||||
|
|
||||||
|
function findCondition(id: string) {
|
||||||
|
const def = CONDITION_DEFINITIONS.find((d) => d.id === id);
|
||||||
|
if (!def) throw new Error(`Condition ${id} not found`);
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("getConditionDescription", () => {
|
||||||
|
it("returns 5.5e description by default", () => {
|
||||||
|
const exhaustion = findCondition("exhaustion");
|
||||||
|
expect(getConditionDescription(exhaustion, "5.5e")).toBe(
|
||||||
|
exhaustion.description,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 5e description when edition is 5e", () => {
|
||||||
|
const exhaustion = findCondition("exhaustion");
|
||||||
|
expect(getConditionDescription(exhaustion, "5e")).toBe(
|
||||||
|
exhaustion.description5e,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every condition has both descriptions", () => {
|
||||||
|
for (const def of CONDITION_DEFINITIONS) {
|
||||||
|
expect(def.description).toBeTruthy();
|
||||||
|
expect(def.description5e).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("conditions with identical rules share the same text", () => {
|
||||||
|
const blinded = findCondition("blinded");
|
||||||
|
expect(blinded.description).toBe(blinded.description5e);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("conditions with different rules have different text", () => {
|
||||||
|
const exhaustion = findCondition("exhaustion");
|
||||||
|
expect(exhaustion.description).not.toBe(exhaustion.description5e);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,20 +15,32 @@ export type ConditionId =
|
|||||||
| "stunned"
|
| "stunned"
|
||||||
| "unconscious";
|
| "unconscious";
|
||||||
|
|
||||||
|
export type RulesEdition = "5e" | "5.5e";
|
||||||
|
|
||||||
export interface ConditionDefinition {
|
export interface ConditionDefinition {
|
||||||
readonly id: ConditionId;
|
readonly id: ConditionId;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly description: string;
|
readonly description: string;
|
||||||
|
readonly description5e: string;
|
||||||
readonly iconName: string;
|
readonly iconName: string;
|
||||||
readonly color: string;
|
readonly color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getConditionDescription(
|
||||||
|
def: ConditionDefinition,
|
||||||
|
edition: RulesEdition,
|
||||||
|
): string {
|
||||||
|
return edition === "5e" ? def.description5e : def.description;
|
||||||
|
}
|
||||||
|
|
||||||
export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||||
{
|
{
|
||||||
id: "blinded",
|
id: "blinded",
|
||||||
label: "Blinded",
|
label: "Blinded",
|
||||||
description:
|
description:
|
||||||
"Can't see. Auto-fail sight checks. Attacks have Disadvantage; attacks against have Advantage.",
|
"Can't see. Auto-fail sight checks. Attacks have Disadvantage; attacks against have Advantage.",
|
||||||
|
description5e:
|
||||||
|
"Can't see. Auto-fail sight checks. Attacks have Disadvantage; attacks against have Advantage.",
|
||||||
iconName: "EyeOff",
|
iconName: "EyeOff",
|
||||||
color: "neutral",
|
color: "neutral",
|
||||||
},
|
},
|
||||||
@@ -37,6 +49,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
label: "Charmed",
|
label: "Charmed",
|
||||||
description:
|
description:
|
||||||
"Can't attack or target the charmer with harmful abilities. Charmer has Advantage on social checks.",
|
"Can't attack or target the charmer with harmful abilities. Charmer has Advantage on social checks.",
|
||||||
|
description5e:
|
||||||
|
"Can't attack or target the charmer with harmful abilities. Charmer has Advantage on social checks.",
|
||||||
iconName: "Heart",
|
iconName: "Heart",
|
||||||
color: "pink",
|
color: "pink",
|
||||||
},
|
},
|
||||||
@@ -44,6 +58,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
id: "deafened",
|
id: "deafened",
|
||||||
label: "Deafened",
|
label: "Deafened",
|
||||||
description: "Can't hear. Auto-fail hearing checks.",
|
description: "Can't hear. Auto-fail hearing checks.",
|
||||||
|
description5e: "Can't hear. Auto-fail hearing checks.",
|
||||||
iconName: "EarOff",
|
iconName: "EarOff",
|
||||||
color: "neutral",
|
color: "neutral",
|
||||||
},
|
},
|
||||||
@@ -51,7 +66,9 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
id: "exhaustion",
|
id: "exhaustion",
|
||||||
label: "Exhaustion",
|
label: "Exhaustion",
|
||||||
description:
|
description:
|
||||||
"Subtract exhaustion level from D20 Tests and Spell save DCs. Speed reduced by 5 ft. \u00D7 level. Removed by long rest (1 level) or death at 10 levels.",
|
"D20 Tests reduced by 2 \u00D7 exhaustion level.\nSpeed reduced by 5 ft. \u00D7 level.\nLong rest removes 1 level.\nDeath at 6 levels.",
|
||||||
|
description5e:
|
||||||
|
"L1: Disadvantage on ability checks\nL2: Speed halved\nL3: Disadvantage on attacks and saves\nL4: HP max halved\nL5: Speed 0\nL6: Death\nLong rest removes 1 level.",
|
||||||
iconName: "BatteryLow",
|
iconName: "BatteryLow",
|
||||||
color: "amber",
|
color: "amber",
|
||||||
},
|
},
|
||||||
@@ -60,6 +77,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
label: "Frightened",
|
label: "Frightened",
|
||||||
description:
|
description:
|
||||||
"Disadvantage on ability checks and attacks while source of fear is in line of sight. Can't willingly move closer to the source.",
|
"Disadvantage on ability checks and attacks while source of fear is in line of sight. Can't willingly move closer to the source.",
|
||||||
|
description5e:
|
||||||
|
"Disadvantage on ability checks and attacks while source of fear is in line of sight. Can't willingly move closer to the source.",
|
||||||
iconName: "Siren",
|
iconName: "Siren",
|
||||||
color: "orange",
|
color: "orange",
|
||||||
},
|
},
|
||||||
@@ -67,7 +86,9 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
id: "grappled",
|
id: "grappled",
|
||||||
label: "Grappled",
|
label: "Grappled",
|
||||||
description:
|
description:
|
||||||
"Speed is 0 and can't benefit from bonuses to speed. Ends if grappler is Incapacitated or moved out of reach.",
|
"Speed 0. Disadvantage on attacks against targets other than the grappler. Grappler can drag you (extra movement cost). Ends if grappler is Incapacitated or you leave their reach.",
|
||||||
|
description5e:
|
||||||
|
"Speed 0. Ends if grappler is Incapacitated or moved out of reach.",
|
||||||
iconName: "Hand",
|
iconName: "Hand",
|
||||||
color: "neutral",
|
color: "neutral",
|
||||||
},
|
},
|
||||||
@@ -75,7 +96,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
id: "incapacitated",
|
id: "incapacitated",
|
||||||
label: "Incapacitated",
|
label: "Incapacitated",
|
||||||
description:
|
description:
|
||||||
"Can't take Actions, Bonus Actions, or Reactions. Concentration is broken.",
|
"Can't take Actions, Bonus Actions, or Reactions. Can't speak. Concentration is broken. Disadvantage on Initiative.",
|
||||||
|
description5e: "Can't take Actions or Reactions.",
|
||||||
iconName: "Ban",
|
iconName: "Ban",
|
||||||
color: "gray",
|
color: "gray",
|
||||||
},
|
},
|
||||||
@@ -83,6 +105,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
id: "invisible",
|
id: "invisible",
|
||||||
label: "Invisible",
|
label: "Invisible",
|
||||||
description:
|
description:
|
||||||
|
"Can't be seen. Advantage on Initiative. Not affected by effects requiring sight (unless caster sees you). Attacks have Advantage; attacks against have Disadvantage.",
|
||||||
|
description5e:
|
||||||
"Impossible to see without magic or special sense. Heavily Obscured. Attacks have Advantage; attacks against have Disadvantage.",
|
"Impossible to see without magic or special sense. Heavily Obscured. Attacks have Advantage; attacks against have Disadvantage.",
|
||||||
iconName: "Ghost",
|
iconName: "Ghost",
|
||||||
color: "violet",
|
color: "violet",
|
||||||
@@ -92,6 +116,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
label: "Paralyzed",
|
label: "Paralyzed",
|
||||||
description:
|
description:
|
||||||
"Incapacitated. Can't move or speak. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
"Incapacitated. Can't move or speak. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||||
|
description5e:
|
||||||
|
"Incapacitated. Can't move or speak. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||||
iconName: "ZapOff",
|
iconName: "ZapOff",
|
||||||
color: "yellow",
|
color: "yellow",
|
||||||
},
|
},
|
||||||
@@ -100,6 +126,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
label: "Petrified",
|
label: "Petrified",
|
||||||
description:
|
description:
|
||||||
"Turned to stone. Weight \u00D710. Incapacitated. Can't move or speak. Attacks against have Advantage. Auto-fail Str/Dex saves. Resistant to all damage. Immune to poison and disease.",
|
"Turned to stone. Weight \u00D710. Incapacitated. Can't move or speak. Attacks against have Advantage. Auto-fail Str/Dex saves. Resistant to all damage. Immune to poison and disease.",
|
||||||
|
description5e:
|
||||||
|
"Turned to stone. Weight \u00D710. Incapacitated. Can't move or speak. Attacks against have Advantage. Auto-fail Str/Dex saves. Resistant to all damage. Immune to poison and disease.",
|
||||||
iconName: "Gem",
|
iconName: "Gem",
|
||||||
color: "slate",
|
color: "slate",
|
||||||
},
|
},
|
||||||
@@ -107,6 +135,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
id: "poisoned",
|
id: "poisoned",
|
||||||
label: "Poisoned",
|
label: "Poisoned",
|
||||||
description: "Disadvantage on attack rolls and ability checks.",
|
description: "Disadvantage on attack rolls and ability checks.",
|
||||||
|
description5e: "Disadvantage on attack rolls and ability checks.",
|
||||||
iconName: "Droplet",
|
iconName: "Droplet",
|
||||||
color: "green",
|
color: "green",
|
||||||
},
|
},
|
||||||
@@ -115,6 +144,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
label: "Prone",
|
label: "Prone",
|
||||||
description:
|
description:
|
||||||
"Can only crawl (costs extra movement). Disadvantage on attacks. Attacks within 5 ft. have Advantage; ranged attacks have Disadvantage. Standing up costs half movement.",
|
"Can only crawl (costs extra movement). Disadvantage on attacks. Attacks within 5 ft. have Advantage; ranged attacks have Disadvantage. Standing up costs half movement.",
|
||||||
|
description5e:
|
||||||
|
"Can only crawl (costs extra movement). Disadvantage on attacks. Attacks within 5 ft. have Advantage; ranged attacks have Disadvantage. Standing up costs half movement.",
|
||||||
iconName: "ArrowDown",
|
iconName: "ArrowDown",
|
||||||
color: "neutral",
|
color: "neutral",
|
||||||
},
|
},
|
||||||
@@ -123,6 +154,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
label: "Restrained",
|
label: "Restrained",
|
||||||
description:
|
description:
|
||||||
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
|
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
|
||||||
|
description5e:
|
||||||
|
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
|
||||||
iconName: "Link",
|
iconName: "Link",
|
||||||
color: "neutral",
|
color: "neutral",
|
||||||
},
|
},
|
||||||
@@ -130,6 +163,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
id: "stunned",
|
id: "stunned",
|
||||||
label: "Stunned",
|
label: "Stunned",
|
||||||
description:
|
description:
|
||||||
|
"Incapacitated (can't act or speak). Auto-fail Str/Dex saves. Attacks against have Advantage.",
|
||||||
|
description5e:
|
||||||
"Incapacitated. Can't move. Can speak only falteringly. Auto-fail Str/Dex saves. Attacks against have Advantage.",
|
"Incapacitated. Can't move. Can speak only falteringly. Auto-fail Str/Dex saves. Attacks against have Advantage.",
|
||||||
iconName: "Sparkles",
|
iconName: "Sparkles",
|
||||||
color: "yellow",
|
color: "yellow",
|
||||||
@@ -138,7 +173,9 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
id: "unconscious",
|
id: "unconscious",
|
||||||
label: "Unconscious",
|
label: "Unconscious",
|
||||||
description:
|
description:
|
||||||
"Incapacitated. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
"Incapacitated. Speed 0. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||||
|
description5e:
|
||||||
|
"Incapacitated. Speed 0. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||||
iconName: "Moon",
|
iconName: "Moon",
|
||||||
color: "indigo",
|
color: "indigo",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export {
|
|||||||
CONDITION_DEFINITIONS,
|
CONDITION_DEFINITIONS,
|
||||||
type ConditionDefinition,
|
type ConditionDefinition,
|
||||||
type ConditionId,
|
type ConditionId,
|
||||||
|
getConditionDescription,
|
||||||
|
type RulesEdition,
|
||||||
VALID_CONDITION_IDS,
|
VALID_CONDITION_IDS,
|
||||||
} from "./conditions.js";
|
} from "./conditions.js";
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -229,12 +229,13 @@ Acceptance scenarios:
|
|||||||
2. **Given** the condition picker is open, **When** the user clicks an active condition, **Then** it is toggled off and removed from the row.
|
2. **Given** the condition picker is open, **When** the user clicks an active condition, **Then** it is toggled off and removed from the row.
|
||||||
3. **Given** a combatant with one condition and it is removed, **Then** only the hover-revealed "+" button remains.
|
3. **Given** a combatant with one condition and it is removed, **Then** only the hover-revealed "+" button remains.
|
||||||
|
|
||||||
**Story CC-3 — View Condition Name via Tooltip (P2)**
|
**Story CC-3 — View Condition Details via Tooltip (P2)**
|
||||||
As a DM, I want to hover over a condition icon to see its name so I can identify conditions without memorizing icons.
|
As a DM, I want to hover over a condition icon to see its name and rules description so I can quickly reference the condition's effects during play.
|
||||||
|
|
||||||
Acceptance scenarios:
|
Acceptance scenarios:
|
||||||
1. **Given** a combatant has an active condition, **When** the user hovers over its icon, **Then** a tooltip shows the condition name (e.g., "Blinded").
|
1. **Given** a combatant has an active condition, **When** the user hovers over its icon, **Then** a tooltip shows the condition name and its rules description matching the selected edition.
|
||||||
2. **Given** the user moves the cursor away from the icon, **Then** the tooltip disappears.
|
2. **Given** the user moves the cursor away from the icon, **Then** the tooltip disappears.
|
||||||
|
3. **Given** the rules edition is set to 5e (2014), **When** the user hovers over "Exhaustion", **Then** the tooltip shows the 2014 exhaustion rules (6-level escalating table). **When** the edition is 5.5e (2024), **Then** the tooltip shows the 2024 rules (−2 per level to d20 tests, −5 ft speed per level).
|
||||||
|
|
||||||
**Story CC-4 — Multiple Conditions (P2)**
|
**Story CC-4 — Multiple Conditions (P2)**
|
||||||
As a DM, I want to apply multiple conditions to a single combatant so I can track complex combat situations.
|
As a DM, I want to apply multiple conditions to a single combatant so I can track complex combat situations.
|
||||||
@@ -272,9 +273,21 @@ Acceptance scenarios:
|
|||||||
3. **Given** a combatant is NOT concentrating, **When** damage is taken, **Then** no pulse/flash occurs.
|
3. **Given** a combatant is NOT concentrating, **When** damage is taken, **Then** no pulse/flash occurs.
|
||||||
4. **Given** a concentrating combatant takes damage, **When** the animation completes, **Then** the row returns to its normal concentration-active appearance.
|
4. **Given** a concentrating combatant takes damage, **When** the animation completes, **Then** the row returns to its normal concentration-active appearance.
|
||||||
|
|
||||||
|
**Story CC-8 — Rules Edition Setting (P2)**
|
||||||
|
As a DM who plays in both 5e (2014) and 5.5e (2024) groups, I want to choose which edition's condition descriptions appear in tooltips so I reference the correct rules for the game I am running.
|
||||||
|
|
||||||
|
Acceptance scenarios:
|
||||||
|
1. **Given** the user opens the kebab menu, **When** they click "Settings", **Then** a settings modal opens.
|
||||||
|
2. **Given** the settings modal is open, **When** viewing the Conditions section, **Then** a rules edition selector shows 5e (2014) and 5.5e (2024) with 5.5e selected by default.
|
||||||
|
3. **Given** the user selects 5e (2014), **When** hovering a condition icon (e.g., Exhaustion), **Then** the tooltip shows the 2014 description.
|
||||||
|
4. **Given** the user selects 5.5e (2024), **When** hovering the same condition, **Then** the tooltip shows the 2024 description.
|
||||||
|
5. **Given** the user changes the edition and reloads the page, **Then** the selected edition is preserved.
|
||||||
|
6. **Given** a condition with identical rules across editions (e.g., Deafened), **Then** the tooltip text is the same regardless of setting.
|
||||||
|
7. **Given** the settings modal is open, **When** viewing the Theme section, **Then** a System / Light / Dark selector is available, replacing the inline cycle button in the kebab menu.
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- **FR-032**: The MVP MUST support the following 15 standard D&D 5e conditions: blinded, charmed, deafened, exhaustion, frightened, grappled, incapacitated, invisible, paralyzed, petrified, poisoned, prone, restrained, stunned, unconscious.
|
- **FR-032**: The MVP MUST support the following 15 standard D&D 5e/5.5e conditions: blinded, charmed, deafened, exhaustion, frightened, grappled, incapacitated, invisible, paralyzed, petrified, poisoned, prone, restrained, stunned, unconscious.
|
||||||
- **FR-033**: Each condition MUST have a fixed icon and color mapping (Lucide icons; no emoji):
|
- **FR-033**: Each condition MUST have a fixed icon and color mapping (Lucide icons; no emoji):
|
||||||
|
|
||||||
| Condition | Icon | Color |
|
| Condition | Icon | Color |
|
||||||
@@ -301,7 +314,7 @@ Acceptance scenarios:
|
|||||||
- **FR-037**: Clicking "+" MUST open the compact condition picker showing all conditions as icon + label pairs.
|
- **FR-037**: Clicking "+" MUST open the compact condition picker showing all conditions as icon + label pairs.
|
||||||
- **FR-038**: Clicking a condition in the picker MUST toggle it on or off.
|
- **FR-038**: Clicking a condition in the picker MUST toggle it on or off.
|
||||||
- **FR-039**: Clicking an active condition icon tag in the row MUST remove that condition.
|
- **FR-039**: Clicking an active condition icon tag in the row MUST remove that condition.
|
||||||
- **FR-040**: Hovering an active condition icon MUST show a tooltip with the condition name.
|
- **FR-040**: Hovering an active condition icon MUST show a tooltip with the condition name and its rules description for the selected edition.
|
||||||
- **FR-041**: Condition icons MUST NOT increase the row's width; row height MAY increase to accommodate wrapping.
|
- **FR-041**: Condition icons MUST NOT increase the row's width; row height MAY increase to accommodate wrapping.
|
||||||
- **FR-042**: The condition picker MUST close when the user clicks outside of it.
|
- **FR-042**: The condition picker MUST close when the user clicks outside of it.
|
||||||
- **FR-043**: Conditions MUST persist as part of combatant state (surviving page reload).
|
- **FR-043**: Conditions MUST persist as part of combatant state (surviving page reload).
|
||||||
@@ -327,6 +340,9 @@ Acceptance scenarios:
|
|||||||
- When concentration is toggled during an active pulse animation, the animation cancels and the new state applies immediately.
|
- When concentration is toggled during an active pulse animation, the animation cancels and the new state applies immediately.
|
||||||
- Multiple combatants may concentrate simultaneously; concentration is independent per combatant.
|
- Multiple combatants may concentrate simultaneously; concentration is independent per combatant.
|
||||||
- Conditions have no mechanical effects in the MVP baseline (no auto-disadvantage, no automation).
|
- Conditions have no mechanical effects in the MVP baseline (no auto-disadvantage, no automation).
|
||||||
|
- When the rules edition preference is missing from localStorage, the system defaults to 5.5e (2024).
|
||||||
|
- Changing the edition while a condition tooltip is visible updates the tooltip on next hover (no live update required).
|
||||||
|
- The settings modal is app-level UI; it does not interact with encounter state.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -472,6 +488,14 @@ Acceptance scenarios:
|
|||||||
- **FR-092**: The "Initiative Tracker" heading MUST be removed to maximize vertical space for combatants.
|
- **FR-092**: The "Initiative Tracker" heading MUST be removed to maximize vertical space for combatants.
|
||||||
- **FR-093**: All interactive elements in the row MUST remain keyboard-accessible (focusable and operable via keyboard).
|
- **FR-093**: All interactive elements in the row MUST remain keyboard-accessible (focusable and operable via keyboard).
|
||||||
- **FR-094**: On touch devices, hover-only controls ("+" button, "x" button) MUST remain accessible via tap or focus.
|
- **FR-094**: On touch devices, hover-only controls ("+" button, "x" button) MUST remain accessible via tap or focus.
|
||||||
|
- **FR-095**: The system MUST provide a settings modal accessible via a "Settings" item in the kebab overflow menu.
|
||||||
|
- **FR-096**: The settings modal MUST include a Conditions section with a rules edition selector offering two options: 5e (2014) and 5.5e (2024).
|
||||||
|
- **FR-097**: The default rules edition MUST be 5.5e (2024).
|
||||||
|
- **FR-098**: Each condition definition MUST carry a description for both editions. Conditions with identical rules across editions MAY share a single description value.
|
||||||
|
- **FR-099**: Condition tooltips MUST display the description corresponding to the active rules edition preference.
|
||||||
|
- **FR-100**: The rules edition preference MUST persist across sessions via localStorage (key `"initiative:rules-edition"`).
|
||||||
|
- **FR-101**: The settings modal MUST include a Theme section with System / Light / Dark options, replacing the inline theme cycle button in the kebab menu.
|
||||||
|
- **FR-102**: The settings modal MUST close on Escape, click-outside, or the close button.
|
||||||
|
|
||||||
### Edge Cases
|
### Edge Cases
|
||||||
|
|
||||||
@@ -515,3 +539,6 @@ Acceptance scenarios:
|
|||||||
- **SC-028**: The AC number is visually identifiable as armor class through the shield shape alone.
|
- **SC-028**: The AC number is visually identifiable as armor class through the shield shape alone.
|
||||||
- **SC-029**: No layout shift occurs when hovering/unhovering rows.
|
- **SC-029**: No layout shift occurs when hovering/unhovering rows.
|
||||||
- **SC-030**: All HP, AC, initiative, condition, and concentration interactions remain fully operable using only a keyboard.
|
- **SC-030**: All HP, AC, initiative, condition, and concentration interactions remain fully operable using only a keyboard.
|
||||||
|
- **SC-031**: The user can switch rules edition in 2 interactions (open settings → select edition).
|
||||||
|
- **SC-032**: Condition tooltips accurately reflect the selected edition's rules text for all conditions that differ between editions.
|
||||||
|
- **SC-033**: The rules edition preference survives a full page reload.
|
||||||
|
|||||||
Reference in New Issue
Block a user