Compare commits
6 Commits
0.7.4
...
02096bcee6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02096bcee6 | ||
|
|
c092192b0e | ||
|
|
4d1a7c6420 | ||
|
|
46b444caba | ||
|
|
e68145319f | ||
|
|
d64e1f5e4a |
@@ -195,26 +195,8 @@ export function App() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Auto-show stat block for the active combatant when turn changes,
|
|
||||||
// but only when the viewport is wide enough to show it alongside the tracker.
|
|
||||||
// Only react to activeIndex changes — not combatant reordering (e.g. Roll All).
|
|
||||||
const prevActiveIndexRef = useRef(encounter.activeIndex);
|
|
||||||
useEffect(() => {
|
|
||||||
if (prevActiveIndexRef.current === encounter.activeIndex) return;
|
|
||||||
prevActiveIndexRef.current = encounter.activeIndex;
|
|
||||||
if (!globalThis.matchMedia("(min-width: 1024px)").matches) return;
|
|
||||||
const active = encounter.combatants[encounter.activeIndex];
|
|
||||||
if (!active?.creatureId || !isLoaded) return;
|
|
||||||
sidePanel.showCreature(active.creatureId);
|
|
||||||
}, [
|
|
||||||
encounter.activeIndex,
|
|
||||||
encounter.combatants,
|
|
||||||
isLoaded,
|
|
||||||
sidePanel.showCreature,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen 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 max-w-2xl flex-1 flex-col gap-3 px-4">
|
||||||
{!!actionBarAnim.showTopBar && (
|
{!!actionBarAnim.showTopBar && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
163
apps/web/src/__tests__/app-integration.test.tsx
Normal file
163
apps/web/src/__tests__/app-integration.test.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { App } from "../App";
|
||||||
|
|
||||||
|
// Mock persistence — no localStorage interaction
|
||||||
|
vi.mock("../persistence/encounter-storage.js", () => ({
|
||||||
|
loadEncounter: () => null,
|
||||||
|
saveEncounter: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../persistence/player-character-storage.js", () => ({
|
||||||
|
loadPlayerCharacters: () => [],
|
||||||
|
savePlayerCharacters: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock bestiary — no IndexedDB or JSON index
|
||||||
|
vi.mock("../adapters/bestiary-cache.js", () => ({
|
||||||
|
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
||||||
|
isSourceCached: () => Promise.resolve(false),
|
||||||
|
cacheSource: () => Promise.resolve(),
|
||||||
|
getCachedSources: () => Promise.resolve([]),
|
||||||
|
clearSource: () => Promise.resolve(),
|
||||||
|
clearAll: () => Promise.resolve(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../adapters/bestiary-index-adapter.js", () => ({
|
||||||
|
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||||
|
getAllSourceCodes: () => [],
|
||||||
|
getDefaultFetchUrl: () => "",
|
||||||
|
getSourceDisplayName: (code: string) => code,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// DOM API stubs — jsdom doesn't implement these
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
Element.prototype.scrollIntoView = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
async function addCombatant(
|
||||||
|
user: ReturnType<typeof userEvent.setup>,
|
||||||
|
name: string,
|
||||||
|
opts?: { maxHp?: string },
|
||||||
|
) {
|
||||||
|
const inputs = screen.getAllByPlaceholderText("+ Add combatants");
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: getAllBy always returns at least one
|
||||||
|
const input = inputs.at(-1)!;
|
||||||
|
await user.type(input, name);
|
||||||
|
|
||||||
|
if (opts?.maxHp) {
|
||||||
|
const maxHpInput = screen.getByPlaceholderText("MaxHP");
|
||||||
|
await user.type(maxHpInput, opts.maxHp);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addButton = screen.getByRole("button", { name: "Add" });
|
||||||
|
await user.click(addButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("App integration", () => {
|
||||||
|
it("adds a combatant and removes it, returning to empty state", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
// Empty state: centered input visible, no TurnNavigation
|
||||||
|
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("R1")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Add a combatant
|
||||||
|
await addCombatant(user, "Goblin");
|
||||||
|
|
||||||
|
// Verify combatant appears and TurnNavigation shows
|
||||||
|
expect(screen.getByRole("button", { name: "Goblin" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("Goblin").length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
// Remove combatant via ConfirmButton (two clicks)
|
||||||
|
const removeBtn = screen.getByRole("button", {
|
||||||
|
name: "Remove combatant",
|
||||||
|
});
|
||||||
|
await user.click(removeBtn);
|
||||||
|
const confirmBtn = screen.getByRole("button", {
|
||||||
|
name: "Confirm remove combatant",
|
||||||
|
});
|
||||||
|
await user.click(confirmBtn);
|
||||||
|
|
||||||
|
// Back to empty state (R1 badge may linger due to exit animation in jsdom)
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Goblin" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advances and retreats turns across two combatants", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await addCombatant(user, "Fighter");
|
||||||
|
await addCombatant(user, "Wizard");
|
||||||
|
|
||||||
|
// Initial state — R1, Fighter active (Previous turn disabled)
|
||||||
|
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Previous turn" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
|
||||||
|
// Advance turn — Wizard becomes active
|
||||||
|
await user.click(screen.getByRole("button", { name: "Next turn" }));
|
||||||
|
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Previous turn" })).toBeEnabled();
|
||||||
|
|
||||||
|
// Advance again — wraps to R2, Fighter active
|
||||||
|
await user.click(screen.getByRole("button", { name: "Next turn" }));
|
||||||
|
expect(screen.getByText("R2")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Previous turn" })).toBeEnabled();
|
||||||
|
|
||||||
|
// Retreat — back to R1, Wizard active
|
||||||
|
await user.click(screen.getByRole("button", { name: "Previous turn" }));
|
||||||
|
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a combatant with HP, applies damage, and shows unconscious state", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await addCombatant(user, "Ogre", { maxHp: "59" });
|
||||||
|
|
||||||
|
// Verify HP displays — currentHp and maxHp both show "59"
|
||||||
|
expect(screen.getByText("/")).toBeInTheDocument();
|
||||||
|
const hpButton = screen.getByRole("button", {
|
||||||
|
name: "Current HP: 59 (healthy)",
|
||||||
|
});
|
||||||
|
expect(hpButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click currentHp to open HpAdjustPopover, apply full damage
|
||||||
|
await user.click(hpButton);
|
||||||
|
const hpInput = screen.getByPlaceholderText("HP");
|
||||||
|
expect(hpInput).toBeInTheDocument();
|
||||||
|
await user.type(hpInput, "59");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Apply damage" }));
|
||||||
|
|
||||||
|
// Verify HP decreased to 0 and unconscious state
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Current HP: 0 (unconscious)" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,6 +51,7 @@ interface RawMonster {
|
|||||||
legendaryHeader?: string[];
|
legendaryHeader?: string[];
|
||||||
spellcasting?: RawSpellcasting[];
|
spellcasting?: RawSpellcasting[];
|
||||||
initiative?: { proficiency?: number };
|
initiative?: { proficiency?: number };
|
||||||
|
_copy?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawEntry {
|
interface RawEntry {
|
||||||
@@ -385,8 +386,7 @@ export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
|
|||||||
// Filter out _copy entries (reference another source's monster) and
|
// Filter out _copy entries (reference another source's monster) and
|
||||||
// monsters missing required fields (ac, hp, size, type)
|
// monsters missing required fields (ac, hp, size, type)
|
||||||
const monsters = raw.monster.filter((m) => {
|
const monsters = raw.monster.filter((m) => {
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: raw JSON may have _copy field
|
if (m._copy) return false;
|
||||||
if ((m as any)._copy) return false;
|
|
||||||
return (
|
return (
|
||||||
Array.isArray(m.ac) &&
|
Array.isArray(m.ac) &&
|
||||||
m.ac.length > 0 &&
|
m.ac.length > 0 &&
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
deriveHpStatus,
|
deriveHpStatus,
|
||||||
type PlayerIcon,
|
type PlayerIcon,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { Brain, X } from "lucide-react";
|
import { BookOpen, Brain, X } from "lucide-react";
|
||||||
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { AcShield } from "./ac-shield";
|
import { AcShield } from "./ac-shield";
|
||||||
@@ -48,21 +48,16 @@ function EditableName({
|
|||||||
name,
|
name,
|
||||||
combatantId,
|
combatantId,
|
||||||
onRename,
|
onRename,
|
||||||
onShowStatBlock,
|
|
||||||
color,
|
color,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
name: string;
|
name: string;
|
||||||
combatantId: CombatantId;
|
combatantId: CombatantId;
|
||||||
onRename: (id: CombatantId, newName: string) => void;
|
onRename: (id: CombatantId, newName: string) => void;
|
||||||
onShowStatBlock?: () => void;
|
|
||||||
color?: string;
|
color?: string;
|
||||||
}>) {
|
}>) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(name);
|
const [draft, setDraft] = useState(name);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
||||||
const longPressTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
||||||
const longPressTriggeredRef = useRef(false);
|
|
||||||
|
|
||||||
const commit = useCallback(() => {
|
const commit = useCallback(() => {
|
||||||
const trimmed = draft.trim();
|
const trimmed = draft.trim();
|
||||||
@@ -78,46 +73,6 @@ function EditableName({
|
|||||||
requestAnimationFrame(() => inputRef.current?.select());
|
requestAnimationFrame(() => inputRef.current?.select());
|
||||||
}, [name]);
|
}, [name]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
clearTimeout(clickTimerRef.current);
|
|
||||||
clearTimeout(longPressTimerRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleClick = useCallback(
|
|
||||||
(e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (longPressTriggeredRef.current) {
|
|
||||||
longPressTriggeredRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (clickTimerRef.current) {
|
|
||||||
clearTimeout(clickTimerRef.current);
|
|
||||||
clickTimerRef.current = undefined;
|
|
||||||
startEditing();
|
|
||||||
} else {
|
|
||||||
clickTimerRef.current = setTimeout(() => {
|
|
||||||
clickTimerRef.current = undefined;
|
|
||||||
onShowStatBlock?.();
|
|
||||||
}, 250);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[startEditing, onShowStatBlock],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTouchStart = useCallback(() => {
|
|
||||||
longPressTriggeredRef.current = false;
|
|
||||||
longPressTimerRef.current = setTimeout(() => {
|
|
||||||
longPressTriggeredRef.current = true;
|
|
||||||
startEditing();
|
|
||||||
}, 500);
|
|
||||||
}, [startEditing]);
|
|
||||||
|
|
||||||
const cancelLongPress = useCallback(() => {
|
|
||||||
clearTimeout(longPressTimerRef.current);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (editing) {
|
if (editing) {
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
@@ -138,11 +93,7 @@ function EditableName({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClick}
|
onClick={startEditing}
|
||||||
onTouchStart={handleTouchStart}
|
|
||||||
onTouchEnd={cancelLongPress}
|
|
||||||
onTouchCancel={cancelLongPress}
|
|
||||||
onTouchMove={cancelLongPress}
|
|
||||||
className="cursor-text truncate text-left text-foreground text-sm transition-colors hover:text-hover-neutral"
|
className="cursor-text truncate text-left text-foreground text-sm transition-colors hover:text-hover-neutral"
|
||||||
style={color ? { color } : undefined}
|
style={color ? { color } : undefined}
|
||||||
>
|
>
|
||||||
@@ -244,6 +195,7 @@ function ClickableHp({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPopoverOpen(true)}
|
onClick={() => setPopoverOpen(true)}
|
||||||
|
aria-label={`Current HP: ${currentHp} (${status})`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral",
|
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral",
|
||||||
status === "bloodied" && "text-amber-400",
|
status === "bloodied" && "text-amber-400",
|
||||||
@@ -427,17 +379,6 @@ function concentrationIconClass(
|
|||||||
return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400";
|
return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400";
|
||||||
}
|
}
|
||||||
|
|
||||||
function activateOnKeyDown(
|
|
||||||
handler: () => void,
|
|
||||||
): (e: { key: string; preventDefault: () => void }) => void {
|
|
||||||
return (e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
handler();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CombatantRow({
|
export function CombatantRow({
|
||||||
ref,
|
ref,
|
||||||
combatant,
|
combatant,
|
||||||
@@ -490,31 +431,19 @@ export function CombatantRow({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
/* biome-ignore lint/a11y/noStaticElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
|
|
||||||
/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
|
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
role={onShowStatBlock ? "button" : undefined}
|
|
||||||
tabIndex={onShowStatBlock ? 0 : undefined}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"group rounded-md pr-3 transition-colors",
|
"group rounded-md pr-3 transition-colors",
|
||||||
rowBorderClass(isActive, combatant.isConcentrating),
|
rowBorderClass(isActive, combatant.isConcentrating),
|
||||||
isPulsing && "animate-concentration-pulse",
|
isPulsing && "animate-concentration-pulse",
|
||||||
onShowStatBlock && "cursor-pointer",
|
|
||||||
)}
|
)}
|
||||||
onClick={onShowStatBlock}
|
|
||||||
onKeyDown={
|
|
||||||
onShowStatBlock ? activateOnKeyDown(onShowStatBlock) : undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-[2rem_3rem_1fr_auto_auto_2rem] items-center gap-3 py-2">
|
<div className="grid grid-cols-[2rem_3rem_1fr_auto_auto_2rem] items-center gap-3 py-2">
|
||||||
{/* Concentration */}
|
{/* Concentration */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={() => onToggleConcentration(id)}
|
||||||
e.stopPropagation();
|
|
||||||
onToggleConcentration(id);
|
|
||||||
}}
|
|
||||||
title="Concentrating"
|
title="Concentrating"
|
||||||
aria-label="Toggle concentration"
|
aria-label="Toggle concentration"
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -526,20 +455,13 @@ export function CombatantRow({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Initiative */}
|
{/* Initiative */}
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
<InitiativeDisplay
|
||||||
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */}
|
initiative={initiative}
|
||||||
<div
|
combatantId={id}
|
||||||
onClick={(e) => e.stopPropagation()}
|
dimmed={dimmed}
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
onSetInitiative={onSetInitiative}
|
||||||
>
|
onRollInitiative={onRollInitiative}
|
||||||
<InitiativeDisplay
|
/>
|
||||||
initiative={initiative}
|
|
||||||
combatantId={id}
|
|
||||||
dimmed={dimmed}
|
|
||||||
onSetInitiative={onSetInitiative}
|
|
||||||
onRollInitiative={onRollInitiative}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Name + Conditions */}
|
{/* Name + Conditions */}
|
||||||
<div
|
<div
|
||||||
@@ -548,6 +470,17 @@ export function CombatantRow({
|
|||||||
dimmed && "opacity-50",
|
dimmed && "opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{!!onShowStatBlock && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onShowStatBlock}
|
||||||
|
title="View stat block"
|
||||||
|
aria-label="View stat block"
|
||||||
|
className="shrink-0 text-muted-foreground transition-colors hover:text-hover-neutral"
|
||||||
|
>
|
||||||
|
<BookOpen size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{!!combatant.icon &&
|
{!!combatant.icon &&
|
||||||
!!combatant.color &&
|
!!combatant.color &&
|
||||||
(() => {
|
(() => {
|
||||||
@@ -568,7 +501,6 @@ export function CombatantRow({
|
|||||||
name={name}
|
name={name}
|
||||||
combatantId={id}
|
combatantId={id}
|
||||||
onRename={onRename}
|
onRename={onRename}
|
||||||
onShowStatBlock={onShowStatBlock}
|
|
||||||
color={pcColor}
|
color={pcColor}
|
||||||
/>
|
/>
|
||||||
<ConditionTags
|
<ConditionTags
|
||||||
@@ -586,24 +518,12 @@ export function CombatantRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AC */}
|
{/* AC */}
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
<div className={cn(dimmed && "opacity-50")}>
|
||||||
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */}
|
|
||||||
<div
|
|
||||||
className={cn(dimmed && "opacity-50")}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
|
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* HP */}
|
{/* HP */}
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
<div className="flex items-center gap-1">
|
||||||
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */}
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-1"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<ClickableHp
|
<ClickableHp
|
||||||
currentHp={currentHp}
|
currentHp={currentHp}
|
||||||
maxHp={maxHp}
|
maxHp={maxHp}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { PlayerCharacter } from "@initiative/domain";
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ColorPalette } from "./color-palette";
|
import { ColorPalette } from "./color-palette";
|
||||||
import { IconGrid } from "./icon-grid";
|
import { IconGrid } from "./icon-grid";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
@@ -25,6 +25,7 @@ export function CreatePlayerModal({
|
|||||||
onSave,
|
onSave,
|
||||||
playerCharacter,
|
playerCharacter,
|
||||||
}: Readonly<CreatePlayerModalProps>) {
|
}: Readonly<CreatePlayerModalProps>) {
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [ac, setAc] = useState("10");
|
const [ac, setAc] = useState("10");
|
||||||
const [maxHp, setMaxHp] = useState("10");
|
const [maxHp, setMaxHp] = useState("10");
|
||||||
@@ -54,15 +55,32 @@ export function CreatePlayerModal({
|
|||||||
}, [open, playerCharacter]);
|
}, [open, playerCharacter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
const dialog = dialogRef.current;
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
if (!dialog) return;
|
||||||
if (e.key === "Escape") onClose();
|
if (open && !dialog.open) {
|
||||||
|
dialog.showModal();
|
||||||
|
} else if (!open && dialog.open) {
|
||||||
|
dialog.close();
|
||||||
}
|
}
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
}, [open]);
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [open, onClose]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
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]);
|
||||||
|
|
||||||
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -86,106 +104,89 @@ export function CreatePlayerModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
|
<dialog
|
||||||
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: backdrop click to close
|
ref={dialogRef}
|
||||||
<div
|
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl backdrop:bg-black/50"
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
|
||||||
onMouseDown={onClose}
|
|
||||||
>
|
>
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
|
<div className="mb-4 flex items-center justify-between">
|
||||||
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: prevent close when clicking modal content */}
|
<h2 className="font-semibold text-foreground text-lg">
|
||||||
<div
|
{isEdit ? "Edit Player" : "Create Player"}
|
||||||
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
|
</h2>
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
<Button
|
||||||
>
|
variant="ghost"
|
||||||
<div className="mb-4 flex items-center justify-between">
|
size="icon"
|
||||||
<h2 className="font-semibold text-foreground text-lg">
|
onClick={onClose}
|
||||||
{isEdit ? "Edit Player" : "Create Player"}
|
className="text-muted-foreground"
|
||||||
</h2>
|
>
|
||||||
<Button
|
<X size={20} />
|
||||||
variant="ghost"
|
</Button>
|
||||||
size="icon"
|
</div>
|
||||||
onClick={onClose}
|
|
||||||
className="text-muted-foreground"
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
>
|
<div>
|
||||||
<X size={20} />
|
<span className="mb-1 block text-muted-foreground text-sm">Name</span>
|
||||||
</Button>
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setName(e.target.value);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
placeholder="Character name"
|
||||||
|
aria-label="Name"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{!!error && <p className="mt-1 text-destructive text-sm">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
<div className="flex gap-3">
|
||||||
<div>
|
<div className="flex-1">
|
||||||
|
<span className="mb-1 block text-muted-foreground text-sm">AC</span>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={ac}
|
||||||
|
onChange={(e) => setAc(e.target.value)}
|
||||||
|
placeholder="AC"
|
||||||
|
aria-label="AC"
|
||||||
|
className="text-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
<span className="mb-1 block text-muted-foreground text-sm">
|
<span className="mb-1 block text-muted-foreground text-sm">
|
||||||
Name
|
Max HP
|
||||||
</span>
|
</span>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
inputMode="numeric"
|
||||||
onChange={(e) => {
|
value={maxHp}
|
||||||
setName(e.target.value);
|
onChange={(e) => setMaxHp(e.target.value)}
|
||||||
setError("");
|
placeholder="Max HP"
|
||||||
}}
|
aria-label="Max HP"
|
||||||
placeholder="Character name"
|
className="text-center"
|
||||||
aria-label="Name"
|
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
{!!error && (
|
|
||||||
<p className="mt-1 text-destructive text-sm">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div>
|
||||||
<div className="flex-1">
|
<span className="mb-2 block text-muted-foreground text-sm">
|
||||||
<span className="mb-1 block text-muted-foreground text-sm">
|
Color
|
||||||
AC
|
</span>
|
||||||
</span>
|
<ColorPalette value={color} onChange={setColor} />
|
||||||
<Input
|
</div>
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={ac}
|
|
||||||
onChange={(e) => setAc(e.target.value)}
|
|
||||||
placeholder="AC"
|
|
||||||
aria-label="AC"
|
|
||||||
className="text-center"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<span className="mb-1 block text-muted-foreground text-sm">
|
|
||||||
Max HP
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={maxHp}
|
|
||||||
onChange={(e) => setMaxHp(e.target.value)}
|
|
||||||
placeholder="Max HP"
|
|
||||||
aria-label="Max HP"
|
|
||||||
className="text-center"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className="mb-2 block text-muted-foreground text-sm">
|
<span className="mb-2 block text-muted-foreground text-sm">Icon</span>
|
||||||
Color
|
<IconGrid value={icon} onChange={setIcon} />
|
||||||
</span>
|
</div>
|
||||||
<ColorPalette value={color} onChange={setColor} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<span className="mb-2 block text-muted-foreground text-sm">
|
<Button type="button" variant="ghost" onClick={onClose}>
|
||||||
Icon
|
Cancel
|
||||||
</span>
|
</Button>
|
||||||
<IconGrid value={icon} onChange={setIcon} />
|
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
</dialog>
|
||||||
<Button type="button" variant="ghost" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||||
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { ConfirmButton } from "./ui/confirm-button";
|
import { ConfirmButton } from "./ui/confirm-button";
|
||||||
@@ -22,102 +22,112 @@ export function PlayerManagement({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onCreate,
|
onCreate,
|
||||||
}: Readonly<PlayerManagementProps>) {
|
}: Readonly<PlayerManagementProps>) {
|
||||||
useEffect(() => {
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
if (!open) return;
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
}
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [open, onClose]);
|
|
||||||
|
|
||||||
if (!open) return 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;
|
||||||
|
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 (
|
return (
|
||||||
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
|
<dialog
|
||||||
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: backdrop click to close
|
ref={dialogRef}
|
||||||
<div
|
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl backdrop:bg-black/50"
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
|
||||||
onMouseDown={onClose}
|
|
||||||
>
|
>
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
|
<div className="mb-4 flex items-center justify-between">
|
||||||
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: prevent close when clicking modal content */}
|
<h2 className="font-semibold text-foreground text-lg">
|
||||||
<div
|
Player Characters
|
||||||
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
|
</h2>
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
<Button
|
||||||
>
|
variant="ghost"
|
||||||
<div className="mb-4 flex items-center justify-between">
|
size="icon"
|
||||||
<h2 className="font-semibold text-foreground text-lg">
|
onClick={onClose}
|
||||||
Player Characters
|
className="text-muted-foreground"
|
||||||
</h2>
|
>
|
||||||
<Button
|
<X size={20} />
|
||||||
variant="ghost"
|
</Button>
|
||||||
size="icon"
|
</div>
|
||||||
onClick={onClose}
|
|
||||||
className="text-muted-foreground"
|
{characters.length === 0 ? (
|
||||||
>
|
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||||
<X size={20} />
|
<p className="text-muted-foreground">No player characters yet</p>
|
||||||
|
<Button onClick={onCreate}>
|
||||||
|
<Plus size={16} />
|
||||||
|
Create your first player character
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
{characters.length === 0 ? (
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
{characters.map((pc) => {
|
||||||
<p className="text-muted-foreground">No player characters yet</p>
|
const Icon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
|
||||||
<Button onClick={onCreate}>
|
const color = pc.color ? PLAYER_COLOR_HEX[pc.color] : undefined;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={pc.id}
|
||||||
|
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-hover-neutral-bg"
|
||||||
|
>
|
||||||
|
{!!Icon && (
|
||||||
|
<Icon size={18} style={{ color }} className="shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="flex-1 truncate text-foreground text-sm">
|
||||||
|
{pc.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
|
AC {pc.ac}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
|
HP {pc.maxHp}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => onEdit(pc)}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</Button>
|
||||||
|
<ConfirmButton
|
||||||
|
icon={<Trash2 size={14} />}
|
||||||
|
label="Delete player character"
|
||||||
|
onConfirm={() => onDelete(pc.id)}
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="mt-2 flex justify-end">
|
||||||
|
<Button onClick={onCreate} variant="ghost">
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
Create your first player character
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
)}
|
||||||
{characters.map((pc) => {
|
</dialog>
|
||||||
const Icon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
|
|
||||||
const color = pc.color ? PLAYER_COLOR_HEX[pc.color] : undefined;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={pc.id}
|
|
||||||
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-hover-neutral-bg"
|
|
||||||
>
|
|
||||||
{!!Icon && (
|
|
||||||
<Icon size={18} style={{ color }} className="shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="flex-1 truncate text-foreground text-sm">
|
|
||||||
{pc.name}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground text-xs tabular-nums">
|
|
||||||
AC {pc.ac}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground text-xs tabular-nums">
|
|
||||||
HP {pc.maxHp}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
onClick={() => onEdit(pc)}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<Pencil size={14} />
|
|
||||||
</Button>
|
|
||||||
<ConfirmButton
|
|
||||||
icon={<Trash2 size={14} />}
|
|
||||||
label="Delete player character"
|
|
||||||
onConfirm={() => onDelete(pc.id)}
|
|
||||||
size="icon-sm"
|
|
||||||
className="text-muted-foreground"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div className="mt-2 flex justify-end">
|
|
||||||
<Button onClick={onCreate} variant="ghost">
|
|
||||||
<Plus size={16} />
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ export function StatBlockPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (panelRole === "pinned") return null;
|
if (panelRole === "pinned" || isCollapsed) return null;
|
||||||
|
|
||||||
return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
|
return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,32 +73,39 @@ export function useBulkImport(): BulkImportHook {
|
|||||||
|
|
||||||
setState((s) => ({ ...s, completed: alreadyCached }));
|
setState((s) => ({ ...s, completed: alreadyCached }));
|
||||||
|
|
||||||
|
const batches: { code: string }[][] = [];
|
||||||
for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
|
for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
|
||||||
const batch = uncached.slice(i, i + BATCH_SIZE);
|
batches.push(uncached.slice(i, i + BATCH_SIZE));
|
||||||
// biome-ignore lint/performance/noAwaitInLoops: sequential batching is intentional to avoid overwhelming the server with too many concurrent requests
|
|
||||||
await Promise.allSettled(
|
|
||||||
batch.map(async ({ code }) => {
|
|
||||||
const url = getDefaultFetchUrl(code, baseUrl);
|
|
||||||
try {
|
|
||||||
await fetchAndCacheSource(code, url);
|
|
||||||
countersRef.current.completed++;
|
|
||||||
} catch (err) {
|
|
||||||
countersRef.current.failed++;
|
|
||||||
console.warn(
|
|
||||||
`[bulk-import] FAILED ${code} (${url}):`,
|
|
||||||
err instanceof Error ? err.message : err,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setState({
|
|
||||||
status: "loading",
|
|
||||||
total,
|
|
||||||
completed: countersRef.current.completed,
|
|
||||||
failed: countersRef.current.failed,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await batches.reduce(
|
||||||
|
(chain, batch) =>
|
||||||
|
chain.then(() =>
|
||||||
|
Promise.allSettled(
|
||||||
|
batch.map(async ({ code }) => {
|
||||||
|
const url = getDefaultFetchUrl(code, baseUrl);
|
||||||
|
try {
|
||||||
|
await fetchAndCacheSource(code, url);
|
||||||
|
countersRef.current.completed++;
|
||||||
|
} catch (err) {
|
||||||
|
countersRef.current.failed++;
|
||||||
|
console.warn(
|
||||||
|
`[bulk-import] FAILED ${code} (${url}):`,
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setState({
|
||||||
|
status: "loading",
|
||||||
|
total,
|
||||||
|
completed: countersRef.current.completed,
|
||||||
|
failed: countersRef.current.failed,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Promise.resolve() as Promise<unknown>,
|
||||||
|
);
|
||||||
|
|
||||||
await refreshCache();
|
await refreshCache();
|
||||||
|
|
||||||
const { completed, failed } = countersRef.current;
|
const { completed, failed } = countersRef.current;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"knip": "knip",
|
"knip": "knip",
|
||||||
"jscpd": "jscpd",
|
"jscpd": "jscpd",
|
||||||
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
|
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
|
||||||
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && tsc --build && vitest run && jscpd"
|
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
||||||
|
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && tsc --build && vitest run && jscpd"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
108
scripts/check-lint-ignores.mjs
Normal file
108
scripts/check-lint-ignores.mjs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Backpressure check for biome-ignore comments.
|
||||||
|
*
|
||||||
|
* 1. Ratcheting cap — source and test files have separate max counts.
|
||||||
|
* Lower these numbers as you fix ignores; they can never go up silently.
|
||||||
|
* 2. Banned rules — ignoring certain rule categories is never allowed.
|
||||||
|
* 3. Justification — every ignore must have a non-empty explanation after
|
||||||
|
* the rule name.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
// ── Configuration ──────────────────────────────────────────────────────
|
||||||
|
const MAX_SOURCE_IGNORES = 2;
|
||||||
|
const MAX_TEST_IGNORES = 3;
|
||||||
|
|
||||||
|
/** Rule prefixes that must never be suppressed. */
|
||||||
|
const BANNED_PREFIXES = [
|
||||||
|
"lint/security/",
|
||||||
|
"lint/correctness/noGlobalObjectCalls",
|
||||||
|
"lint/correctness/noUnsafeFinally",
|
||||||
|
];
|
||||||
|
// ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const IGNORE_PATTERN = /biome-ignore\s+([\w/]+)(?::\s*(.*))?/;
|
||||||
|
|
||||||
|
function findFiles() {
|
||||||
|
return execSync("git ls-files -- '*.ts' '*.tsx'", { encoding: "utf-8" })
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTestFile(path) {
|
||||||
|
return (
|
||||||
|
path.includes("__tests__/") ||
|
||||||
|
path.endsWith(".test.ts") ||
|
||||||
|
path.endsWith(".test.tsx")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let errors = 0;
|
||||||
|
let sourceCount = 0;
|
||||||
|
let testCount = 0;
|
||||||
|
|
||||||
|
for (const file of findFiles()) {
|
||||||
|
const lines = readFileSync(file, "utf-8").split("\n");
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const match = lines[i].match(IGNORE_PATTERN);
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
const rule = match[1];
|
||||||
|
const justification = (match[2] ?? "").trim();
|
||||||
|
const loc = `${file}:${i + 1}`;
|
||||||
|
|
||||||
|
// Count by category
|
||||||
|
if (isTestFile(file)) {
|
||||||
|
testCount++;
|
||||||
|
} else {
|
||||||
|
sourceCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Banned rules
|
||||||
|
for (const prefix of BANNED_PREFIXES) {
|
||||||
|
if (rule.startsWith(prefix)) {
|
||||||
|
console.error(`BANNED: ${loc} — ${rule} must not be suppressed`);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Justification required
|
||||||
|
if (!justification) {
|
||||||
|
console.error(
|
||||||
|
`MISSING JUSTIFICATION: ${loc} — biome-ignore ${rule} needs an explanation after the colon`,
|
||||||
|
);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ratcheting caps
|
||||||
|
if (sourceCount > MAX_SOURCE_IGNORES) {
|
||||||
|
console.error(
|
||||||
|
`SOURCE CAP EXCEEDED: ${sourceCount} biome-ignore comments in source (max ${MAX_SOURCE_IGNORES}). Fix issues and lower the cap.`,
|
||||||
|
);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testCount > MAX_TEST_IGNORES) {
|
||||||
|
console.error(
|
||||||
|
`TEST CAP EXCEEDED: ${testCount} biome-ignore comments in tests (max ${MAX_TEST_IGNORES}). Fix issues and lower the cap.`,
|
||||||
|
);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
console.log(
|
||||||
|
`biome-ignore: ${sourceCount} source (max ${MAX_SOURCE_IGNORES}), ${testCount} test (max ${MAX_TEST_IGNORES})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (errors > 0) {
|
||||||
|
console.error(`\n${errors} problem(s) found.`);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log("All checks passed.");
|
||||||
|
}
|
||||||
@@ -116,19 +116,17 @@ A user attempts to edit a combatant that no longer exists or provides an invalid
|
|||||||
|
|
||||||
**Story C3 — Rename trigger UX (Priority: P1)**
|
**Story C3 — Rename trigger UX (Priority: P1)**
|
||||||
|
|
||||||
A user wants to rename a combatant. Single-clicking the name opens the stat block panel instead of entering edit mode. To rename, the user double-clicks the name or long-presses on touch devices. A `cursor-text` cursor on hover signals that the name is editable.
|
A user wants to rename a combatant. Clicking the combatant's name immediately enters inline edit mode — no delay, no timer, consistent for all combatant types. A `cursor-text` cursor on hover signals that the name is editable. Stat block access is handled separately via a dedicated book icon (see `specs/004-bestiary/spec.md`, FR-062).
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
1. **Given** a combatant row is visible, **When** the user single-clicks the combatant name, **Then** the stat block panel opens or toggles — inline edit mode is NOT entered.
|
1. **Given** a combatant row is visible, **When** the user clicks the combatant name, **Then** inline edit mode is entered immediately for that combatant's name — no delay or timer.
|
||||||
|
|
||||||
2. **Given** a combatant row is visible, **When** the user double-clicks the combatant name, **Then** inline edit mode is entered for that combatant's name.
|
2. **Given** a combatant row is visible on a pointer device, **When** the user hovers over the combatant name, **Then** the cursor changes to a text cursor (`cursor-text`) to signal editability.
|
||||||
|
|
||||||
3. **Given** a combatant row is visible on a pointer device, **When** the user hovers over the combatant name, **Then** the cursor changes to a text cursor (`cursor-text`) to signal editability.
|
3. **Given** inline edit mode has been entered, **When** the user types a new name and presses Enter or blurs the field, **Then** the name is committed. **When** the user presses Escape, **Then** the edit is cancelled and the original name is restored.
|
||||||
|
|
||||||
4. **Given** a combatant row is visible on a touch device, **When** the user long-presses the combatant name, **Then** inline edit mode is entered for that combatant's name.
|
4. **Given** a bestiary combatant row and a custom combatant row, **When** the user clicks either combatant's name, **Then** the behavior is identical — inline edit mode is entered immediately in both cases.
|
||||||
|
|
||||||
7. **Given** inline edit mode has been entered (via any trigger), **When** the user types a new name and presses Enter or blurs the field, **Then** the name is committed. **When** the user presses Escape, **Then** the edit is cancelled and the original name is restored.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -291,7 +289,7 @@ EditCombatant MUST return an `"invalid-name"` error when the new name is empty o
|
|||||||
EditCombatant MUST preserve the combatant's position in the list, `activeIndex`, and `roundNumber`. Setting a name to the same value it already has is treated as a valid update; a `CombatantUpdated` event is still emitted.
|
EditCombatant MUST preserve the combatant's position in the list, `activeIndex`, and `roundNumber`. Setting a name to the same value it already has is treated as a valid update; a `CombatantUpdated` event is still emitted.
|
||||||
|
|
||||||
#### FR-024 — Edit: UI
|
#### FR-024 — Edit: UI
|
||||||
The UI MUST provide an inline name-edit mechanism for each combatant, activated by double-clicking the name or long-pressing on touch devices. The name MUST display a `cursor-text` cursor on hover to signal editability. Single-clicking the name MUST open/toggle the stat block panel, not enter edit mode. The updated name MUST be immediately visible after submission.
|
The UI MUST provide an inline name-edit mechanism for each combatant, activated by a single click on the name. Clicking the name MUST enter inline edit mode immediately — no delay, no timer, consistent for all combatant types. The name MUST display a `cursor-text` cursor on hover to signal editability. The updated name MUST be immediately visible after submission. The 250ms click timer and double-click detection logic MUST be removed entirely.
|
||||||
|
|
||||||
#### FR-025 — ConfirmButton: Reusable component
|
#### FR-025 — ConfirmButton: Reusable component
|
||||||
The system MUST provide a reusable `ConfirmButton` component that wraps any icon button to add a two-step confirmation flow.
|
The system MUST provide a reusable `ConfirmButton` component that wraps any icon button to add a two-step confirmation flow.
|
||||||
@@ -364,9 +362,7 @@ All domain events MUST be returned as plain data values from operations, not dis
|
|||||||
- **ConfirmButton: component unmounts in confirm state**: The auto-revert timer MUST be cleaned up to prevent memory leaks or stale state updates.
|
- **ConfirmButton: component unmounts in confirm state**: The auto-revert timer MUST be cleaned up to prevent memory leaks or stale state updates.
|
||||||
- **ConfirmButton: two instances in confirm state simultaneously**: Each manages its own state independently.
|
- **ConfirmButton: two instances in confirm state simultaneously**: Each manages its own state independently.
|
||||||
- **ConfirmButton: combatant row re-renders while in confirm state**: Confirm state persists through re-renders as long as combatant identity is stable.
|
- **ConfirmButton: combatant row re-renders while in confirm state**: Confirm state persists through re-renders as long as combatant identity is stable.
|
||||||
- **Name single-click vs double-click**: A single click on the combatant name opens the stat block panel; only a completed double-click enters inline edit mode. The system must disambiguate between the two gestures.
|
- **Name click behavior is uniform**: A single click on any combatant's name enters inline edit mode immediately. There is no gesture disambiguation (no timer, no double-click detection). Stat block access is handled via the dedicated book icon on bestiary rows (see `specs/004-bestiary/spec.md`, FR-062).
|
||||||
- **Touch edit affordance**: No hover-dependent affordance is shown on touch devices. Long-press is the touch equivalent for entering edit mode.
|
|
||||||
- **Long-press threshold**: The long-press duration should follow platform conventions (typically ~500ms). A short tap must not trigger edit mode.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -401,4 +397,4 @@ All domain events MUST be returned as plain data values from operations, not dis
|
|||||||
- Cross-tab synchronization is not required for the MVP baseline.
|
- Cross-tab synchronization is not required for the MVP baseline.
|
||||||
- The `ConfirmButton` 5-second timeout is a fixed value and is not configurable in the MVP baseline.
|
- The `ConfirmButton` 5-second timeout is a fixed value and is not configurable in the MVP baseline.
|
||||||
- The `Check` icon from the Lucide icon library is used for the `ConfirmButton` confirm state.
|
- The `Check` icon from the Lucide icon library is used for the `ConfirmButton` confirm state.
|
||||||
- The inline name-edit mechanism is activated by double-click or long-press (touch). A `cursor-text` cursor on hover signals editability. Single-clicking the name opens the stat block panel.
|
- The inline name-edit mechanism is activated by a single click on the name. A `cursor-text` cursor on hover signals editability. There is no double-click or long-press gesture; stat block access uses a dedicated book icon on bestiary rows.
|
||||||
|
|||||||
@@ -419,16 +419,15 @@ Acceptance scenarios:
|
|||||||
3. **Given** any combatant row, **When** hovered and concentration is inactive, **Then** the Brain icon becomes visible.
|
3. **Given** any combatant row, **When** hovered and concentration is inactive, **Then** the Brain icon becomes visible.
|
||||||
4. **Given** the remove button appears on hover, **Then** no layout shift occurs — space is reserved.
|
4. **Given** the remove button appears on hover, **Then** no layout shift occurs — space is reserved.
|
||||||
|
|
||||||
**Story ROW-3 — Row Click Opens Stat Block (P1)**
|
**Story ROW-3 — Book Icon Opens Stat Block (P1)**
|
||||||
As a DM, I want to click anywhere on a bestiary combatant row to open its stat block so I have a large click target and a cleaner row without a dedicated book icon.
|
As a DM, I want a dedicated book icon on bestiary combatant rows so I can open the stat block with an explicit, discoverable control — while clicking the name always starts a rename.
|
||||||
|
|
||||||
Acceptance scenarios:
|
Acceptance scenarios:
|
||||||
1. **Given** a combatant has a linked bestiary creature, **When** the user clicks the name text or empty row space, **Then** the stat block panel opens.
|
1. **Given** a combatant has a linked bestiary creature, **When** the user views the row, **Then** a small BookOpen icon is visible next to the name.
|
||||||
2. **Given** the user clicks an interactive element (initiative, HP, AC, condition icon, "+", "x", concentration), **Then** the stat block does NOT open — the element's own action fires.
|
2. **Given** a combatant does NOT have a linked creature, **When** the user views the row, **Then** no BookOpen icon is displayed.
|
||||||
3. **Given** a combatant does NOT have a linked creature, **When** the row is clicked, **Then** nothing happens.
|
3. **Given** a bestiary combatant row, **When** the user clicks the BookOpen icon, **Then** the stat block panel opens for that creature.
|
||||||
4. **Given** viewing any bestiary combatant row, **Then** no BookOpen icon is visible.
|
4. **Given** the user clicks the combatant's name, **Then** inline rename mode is entered — the stat block does NOT open.
|
||||||
5. **Given** a bestiary combatant row, **When** the user hovers over non-interactive areas, **Then** the cursor indicates clickability.
|
5. **Given** the stat block is already open for a creature, **When** the user clicks its BookOpen icon again, **Then** the panel closes (toggle behavior).
|
||||||
6. **Given** the stat block is already open for a creature, **When** the same row is clicked, **Then** the panel closes (toggle behavior).
|
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
@@ -436,10 +435,10 @@ Acceptance scenarios:
|
|||||||
- **FR-081**: The "+" condition button MUST be hidden at rest and appear on row hover (or touch/focus on touch devices).
|
- **FR-081**: The "+" condition button MUST be hidden at rest and appear on row hover (or touch/focus on touch devices).
|
||||||
- **FR-082**: The remove (x) button MUST be hidden at rest and appear on row hover (or touch/focus on touch devices).
|
- **FR-082**: The remove (x) button MUST be hidden at rest and appear on row hover (or touch/focus on touch devices).
|
||||||
- **FR-083**: Layout space for the remove button MUST be reserved so appearing/disappearing does not cause layout shifts.
|
- **FR-083**: Layout space for the remove button MUST be reserved so appearing/disappearing does not cause layout shifts.
|
||||||
- **FR-084**: Clicking non-interactive areas of a bestiary combatant row MUST open the stat block panel.
|
- **FR-084**: Bestiary-linked combatant rows MUST display a BookOpen icon as the dedicated stat block trigger (see also `specs/004-bestiary/spec.md`, FR-062).
|
||||||
- **FR-085**: Clicking interactive elements (initiative, HP, AC, conditions, "+", "x", concentration) MUST NOT trigger the stat block — only the element's own action.
|
- **FR-085**: Clicking the combatant name MUST enter inline rename mode, not open the stat block.
|
||||||
- **FR-086**: The BookOpen icon MUST be removed from the combatant row.
|
- **FR-086**: Non-bestiary combatant rows MUST NOT display the BookOpen icon.
|
||||||
- **FR-087**: Bestiary combatant rows MUST show a pointer cursor on hover over non-interactive areas.
|
- **FR-087**: The BookOpen icon MUST have a tooltip ("View stat block") and `aria-label="View stat block"` for accessibility.
|
||||||
- **FR-088**: All existing interactions (condition add/remove, HP adjustment, AC editing, initiative editing/rolling, concentration toggle, combatant removal) MUST continue to work.
|
- **FR-088**: All existing interactions (condition add/remove, HP adjustment, AC editing, initiative editing/rolling, concentration toggle, combatant removal) MUST continue to work.
|
||||||
- **FR-089**: Browser scrollbars MUST be styled to match the dark UI theme (thin, dark-colored scrollbar thumbs).
|
- **FR-089**: Browser scrollbars MUST be styled to match the dark UI theme (thin, dark-colored scrollbar thumbs).
|
||||||
- **FR-090**: Turn navigation (Previous/Next) MUST use StepBack/StepForward icons in outline button style with foreground-colored borders. Utility actions (d20/trash) MUST use ghost button style to create clear visual hierarchy.
|
- **FR-090**: Turn navigation (Previous/Next) MUST use StepBack/StepForward icons in outline button style with foreground-colored borders. Utility actions (d20/trash) MUST use ghost button style to create clear visual hierarchy.
|
||||||
@@ -452,8 +451,8 @@ Acceptance scenarios:
|
|||||||
|
|
||||||
- When a combatant has so many conditions that they exceed the available inline space, they wrap within the name column; row height increases but width does not.
|
- When a combatant has so many conditions that they exceed the available inline space, they wrap within the name column; row height increases but width does not.
|
||||||
- The condition picker dropdown positions relative to the "+" button, flipping vertically if near the viewport edge.
|
- The condition picker dropdown positions relative to the "+" button, flipping vertically if near the viewport edge.
|
||||||
- When the stat block panel is already open and the user clicks the same row again, the panel closes.
|
- When the stat block panel is already open and the user clicks the same BookOpen icon again, the panel closes.
|
||||||
- Clicking the initiative area starts editing; it does not open the stat block.
|
- Clicking the combatant name starts inline rename; it does not open the stat block.
|
||||||
- Tablet-width screens (>= 768 px / Tailwind `md`): popovers and inline edits MUST remain accessible and not overflow or clip.
|
- Tablet-width screens (>= 768 px / Tailwind `md`): popovers and inline edits MUST remain accessible and not overflow or clip.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The Bestiary feature provides creature search across a pre-indexed library of 3,312+ creatures from 102+ D&D sources, stat block display for full creature reference during combat, source data management via on-demand fetch or file upload, and a dual-panel UX with fold/unfold and pin capabilities. Creatures can be added individually or in batch from a search dropdown, with stats pre-filled from the index.
|
The Bestiary feature provides creature search across a pre-indexed library of 3,312+ creatures from 102+ D&D sources, stat block display for full creature reference during combat, source data management via on-demand fetch or file upload, and a dual-panel UX with collapse/expand and pin capabilities. Creatures can be added individually or in batch from a search dropdown, with stats pre-filled from the index.
|
||||||
|
|
||||||
The architecture uses a two-tier design: a lightweight search index (`data/bestiary/index.json`) shipped with the app containing mechanical facts for all creatures, and full stat block data loaded on-demand at the source level, normalized, and cached in IndexedDB.
|
The architecture uses a two-tier design: a lightweight search index (`data/bestiary/index.json`) shipped with the app containing mechanical facts for all creatures, and full stat block data loaded on-demand at the source level, normalized, and cached in IndexedDB.
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ When the search input has no bestiary matches (or fewer than 2 characters typed)
|
|||||||
**US-D1 — View Full Stat Block in Side Panel (P2)**
|
**US-D1 — View Full Stat Block in Side Panel (P2)**
|
||||||
As a DM, I want to see the full stat block of a creature displayed in a side panel so that I can reference its abilities, actions, and traits during combat without switching to another tool.
|
As a DM, I want to see the full stat block of a creature displayed in a side panel so that I can reference its abilities, actions, and traits during combat without switching to another tool.
|
||||||
|
|
||||||
When a creature is selected from search results or when clicking a bestiary-linked combatant in the tracker, a stat block panel appears showing the creature's full information in the classic D&D stat block layout. Clicking a different combatant updates the panel to that creature's data.
|
When a creature is selected from search results or when clicking the book icon on a bestiary-linked combatant row, a stat block panel appears showing the creature's full information in the classic D&D stat block layout. Clicking the book icon on a different combatant updates the panel to that creature's data. The book icon is the only way to manually open a stat block from the tracker — clicking the combatant's name always enters inline rename mode (see `specs/001-combatant-management/spec.md`, FR-024).
|
||||||
|
|
||||||
**US-D2 — Preview Stat Block from Search Dropdown (P3)**
|
**US-D2 — Preview Stat Block from Search Dropdown (P3)**
|
||||||
As a DM, I want to preview a creature's stat block directly from the search dropdown so I can review creature details before deciding to add them to the encounter.
|
As a DM, I want to preview a creature's stat block directly from the search dropdown so I can review creature details before deciding to add them to the encounter.
|
||||||
@@ -103,18 +103,22 @@ As a DM using the app on different devices, I want the layout to adapt between s
|
|||||||
- **FR-020**: On wide viewports (desktop), the layout MUST be side-by-side with the encounter tracker on the left and stat block on the right.
|
- **FR-020**: On wide viewports (desktop), the layout MUST be side-by-side with the encounter tracker on the left and stat block on the right.
|
||||||
- **FR-021**: On narrow viewports (mobile), the stat block MUST appear as a dismissible drawer or slide-over.
|
- **FR-021**: On narrow viewports (mobile), the stat block MUST appear as a dismissible drawer or slide-over.
|
||||||
- **FR-022**: The stat block panel MUST scroll independently of the encounter tracker.
|
- **FR-022**: The stat block panel MUST scroll independently of the encounter tracker.
|
||||||
- **FR-023**: When the user clicks a different bestiary-linked combatant in the tracker, the stat block panel MUST update to show that creature's data.
|
- **FR-023**: When the user clicks the book icon on a different bestiary-linked combatant row, the stat block panel MUST update to show that creature's data.
|
||||||
- **FR-024**: The existing search/view button MUST be repurposed to open the stat block for the currently focused/selected creature in the dropdown.
|
- **FR-024**: The existing search/view button MUST be repurposed to open the stat block for the currently focused/selected creature in the dropdown.
|
||||||
|
- **FR-062**: Bestiary-linked combatant rows MUST display a small book icon (Lucide `BookOpen`) as the dedicated stat block trigger. The icon MUST have a tooltip ("View stat block") and an `aria-label="View stat block"` for accessibility. The book icon is the only way to manually open a stat block from the tracker — clicking the combatant name always enters inline rename mode. Non-bestiary combatant rows MUST NOT display the book icon.
|
||||||
|
|
||||||
### Acceptance Scenarios
|
### Acceptance Scenarios
|
||||||
|
|
||||||
1. **Given** a creature is selected from the bestiary search, **When** the stat block panel opens, **Then** it displays: name, size, type, alignment, AC, HP (average and formula), speed, ability scores with modifiers, saving throws, skills, damage resistances/immunities, condition immunities, senses, languages, challenge rating, traits, actions, and legendary actions (if applicable).
|
1. **Given** a creature is selected from the bestiary search, **When** the stat block panel opens, **Then** it displays: name, size, type, alignment, AC, HP (average and formula), speed, ability scores with modifiers, saving throws, skills, damage resistances/immunities, condition immunities, senses, languages, challenge rating, traits, actions, and legendary actions (if applicable).
|
||||||
2. **Given** the stat block panel is open on desktop (wide viewport), **Then** the layout is side-by-side: encounter tracker on the left, stat block panel on the right.
|
2. **Given** the stat block panel is open on desktop (wide viewport), **Then** the layout is side-by-side: encounter tracker on the left, stat block panel on the right.
|
||||||
3. **Given** the stat block panel is open on mobile (narrow viewport), **Then** the stat block appears as a slide-over drawer that can be dismissed.
|
3. **Given** the stat block panel is open on mobile (narrow viewport), **Then** the stat block appears as a slide-over drawer that can be dismissed.
|
||||||
4. **Given** a stat block is displayed, **When** the user clicks a different bestiary-linked combatant in the tracker, **Then** the stat block panel updates to show that creature's data.
|
4. **Given** a stat block is displayed, **When** the user clicks the book icon on a different bestiary-linked combatant row, **Then** the stat block panel updates to show that creature's data.
|
||||||
5. **Given** a creature entry contains markup tags (e.g., spell references, dice notation), **Then** they render as plain text.
|
5. **Given** a creature entry contains markup tags (e.g., spell references, dice notation), **Then** they render as plain text.
|
||||||
6. **Given** the dropdown is showing bestiary results, **When** the user clicks the stat block view button, **Then** the stat block panel opens for the currently focused/highlighted creature in the dropdown.
|
6. **Given** the dropdown is showing bestiary results, **When** the user clicks the stat block view button, **Then** the stat block panel opens for the currently focused/highlighted creature in the dropdown.
|
||||||
7. **Given** no creature is focused in the dropdown, **When** the user clicks the stat block view button, **Then** nothing happens (button is disabled or no-op).
|
7. **Given** no creature is focused in the dropdown, **When** the user clicks the stat block view button, **Then** nothing happens (button is disabled or no-op).
|
||||||
|
8. **Given** a bestiary-linked combatant row is visible, **When** the user looks at the row, **Then** a small book icon is visible as the stat block trigger with a tooltip "View stat block".
|
||||||
|
9. **Given** a custom (non-bestiary) combatant row is visible, **When** the user looks at the row, **Then** no book icon is displayed.
|
||||||
|
10. **Given** a bestiary-linked combatant row is visible, **When** the user clicks the combatant's name, **Then** inline rename mode is entered — the stat block does NOT open.
|
||||||
|
|
||||||
### Edge Cases
|
### Edge Cases
|
||||||
|
|
||||||
@@ -210,58 +214,58 @@ A DM wants to see which sources are cached, clear a specific source's cache, or
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Panel UX (Fold, Pin, Second Panel)
|
## Panel UX (Collapse, Pin, Second Panel)
|
||||||
|
|
||||||
### User Stories
|
### User Stories
|
||||||
|
|
||||||
**US-P1 — Fold and Unfold Stat Block Panel (P1)**
|
**US-P1 — Collapse and Expand Stat Block Panel (P1)**
|
||||||
As a DM running an encounter, I want to collapse the stat block panel to a slim tab so I can temporarily reclaim screen space without losing my place, then quickly expand it again to reference creature stats.
|
As a DM running an encounter, I want to collapse the stat block panel to a slim tab so I can temporarily reclaim screen space without losing my place, then quickly expand it again to reference creature stats.
|
||||||
|
|
||||||
The close button is replaced with a fold/unfold toggle. Folding slides the panel out to the right edge, leaving a slim vertical tab displaying the creature's name. Clicking the tab unfolds the panel, showing the same creature that was displayed before folding. No "Stat Block" heading text is shown in the panel header.
|
The close button is replaced with a collapse/expand toggle. Collapsing slides the panel out to the right edge, leaving a slim vertical tab displaying the creature's name. Clicking the tab expands the panel, showing the same creature that was displayed before collapsing. No "Stat Block" heading text is shown in the panel header.
|
||||||
|
|
||||||
**US-P2 — Pin Creature to Second Panel (P2)**
|
**US-P2 — Pin Creature to Second Panel (P2)**
|
||||||
As a DM comparing creatures or referencing one creature while browsing others, I want to pin the current creature to a secondary panel on the left side of the screen so I can keep it visible while browsing different creatures in the right panel.
|
As a DM comparing creatures or referencing one creature while browsing others, I want to pin the current creature to a secondary panel on the left side of the screen so I can keep it visible while browsing different creatures in the right panel.
|
||||||
|
|
||||||
Clicking the pin button copies the current creature to a new left panel. The right panel remains active for browsing different creatures independently. The left panel has an unpin button that removes it.
|
Clicking the pin button copies the current creature to a new left panel. The right panel remains active for browsing different creatures independently. The left panel has an unpin button that removes it.
|
||||||
|
|
||||||
**US-P3 — Fold Behavior with Pinned Panel (P3)**
|
**US-P3 — Collapse Behavior with Pinned Panel (P3)**
|
||||||
As a DM with a creature pinned, I want to fold the right (browse) panel independently so I can focus on just the pinned creature, or fold both panels to see the full encounter list.
|
As a DM with a creature pinned, I want to collapse the right (browse) panel independently so I can focus on just the pinned creature, or collapse both panels to see the full encounter list.
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- **FR-050**: The system MUST replace the close button on the stat block panel with a fold/unfold toggle control.
|
- **FR-050**: The system MUST replace the close button on the stat block panel with a collapse/expand toggle control.
|
||||||
- **FR-051**: The system MUST remove the "Stat Block" heading text from the panel header.
|
- **FR-051**: The system MUST remove the "Stat Block" heading text from the panel header.
|
||||||
- **FR-052**: When folded, the panel MUST collapse to a slim vertical tab anchored to the right edge of the screen displaying the creature's name.
|
- **FR-052**: When collapsed, the panel MUST reduce to a slim vertical tab anchored to the right edge of the screen displaying the creature's name.
|
||||||
- **FR-053**: Folding and unfolding MUST use a smooth CSS slide animation (~200ms ease-out).
|
- **FR-053**: Collapsing and expanding MUST use a smooth CSS slide animation (~200ms ease-out).
|
||||||
- **FR-054**: The fold/unfold toggle MUST preserve the currently displayed creature — unfolding shows the same creature that was visible when folded.
|
- **FR-054**: The collapse/expand toggle MUST preserve the currently displayed creature — expanding shows the same creature that was visible when collapsed.
|
||||||
- **FR-055**: The panel MUST include a pin button that copies the current creature to a new panel on the left side of the screen.
|
- **FR-055**: The panel MUST include a pin button that copies the current creature to a new panel on the left side of the screen.
|
||||||
- **FR-056**: After pinning, the right panel MUST remain active for browsing different creatures independently.
|
- **FR-056**: After pinning, the right panel MUST remain active for browsing different creatures independently.
|
||||||
- **FR-057**: The pinned (left) panel MUST include an unpin button that removes it when clicked.
|
- **FR-057**: The pinned (left) panel MUST include an unpin button that removes it when clicked.
|
||||||
- **FR-058**: The pin button MUST be hidden on viewports where dual panels would not fit (small screens / mobile).
|
- **FR-058**: The pin button MUST be hidden on viewports where dual panels would not fit (small screens / mobile).
|
||||||
- **FR-059**: The pin button MUST be hidden when the panel is showing a source fetch prompt (no creature data displayed yet).
|
- **FR-059**: The pin button MUST be hidden when the panel is showing a source fetch prompt (no creature data displayed yet).
|
||||||
- **FR-060**: On mobile viewports, the existing drawer/backdrop behavior MUST be preserved — fold/unfold replaces only the close button behavior for the desktop layout; the backdrop click still dismisses the panel.
|
- **FR-060**: On mobile viewports, the existing drawer/backdrop behavior MUST be preserved — collapse/expand replaces only the close button behavior for the desktop layout; the backdrop click still dismisses the panel.
|
||||||
- **FR-061**: Both the browse (right) and pinned (left) panels MUST have independent fold states.
|
- **FR-061**: Both the browse (right) and pinned (left) panels MUST have independent collapsed states.
|
||||||
|
|
||||||
### Acceptance Scenarios
|
### Acceptance Scenarios
|
||||||
|
|
||||||
1. **Given** the stat block panel is open showing a creature, **When** the user clicks the fold button, **Then** the panel slides out to the right edge and a slim vertical tab appears showing the creature's name.
|
1. **Given** the stat block panel is open showing a creature, **When** the user clicks the collapse button, **Then** the panel slides out to the right edge and a slim vertical tab appears showing the creature's name.
|
||||||
2. **Given** the stat block panel is folded to a tab, **When** the user clicks the tab, **Then** the panel slides back in showing the same creature that was displayed before folding.
|
2. **Given** the stat block panel is collapsed to a tab, **When** the user clicks the tab, **Then** the panel slides back in showing the same creature that was displayed before collapsing.
|
||||||
3. **Given** the stat block panel is open, **When** the user looks for a close button, **Then** no close button is present — only a fold toggle.
|
3. **Given** the stat block panel is open, **When** the user looks for a close button, **Then** no close button is present — only a collapse toggle.
|
||||||
4. **Given** the stat block panel is open, **When** the user looks at the panel header, **Then** no "Stat Block" heading text is visible.
|
4. **Given** the stat block panel is open, **When** the user looks at the panel header, **Then** no "Stat Block" heading text is visible.
|
||||||
5. **Given** the panel is folding or unfolding, **When** the animation plays, **Then** it completes with a smooth slide transition (~200ms ease-out).
|
5. **Given** the panel is collapsing or expanding, **When** the animation plays, **Then** it completes with a smooth slide transition (~200ms ease-out).
|
||||||
6. **Given** the stat block panel is showing a creature on a wide screen, **When** the user clicks the pin button, **Then** the current creature appears in a new panel on the left side of the screen.
|
6. **Given** the stat block panel is showing a creature on a wide screen, **When** the user clicks the pin button, **Then** the current creature appears in a new panel on the left side of the screen.
|
||||||
7. **Given** a creature is pinned to the left panel, **When** the user clicks a different combatant in the encounter list, **Then** the right panel updates to show the new creature while the left panel continues showing the pinned creature.
|
7. **Given** a creature is pinned to the left panel, **When** the user clicks the book icon on a different bestiary combatant, **Then** the right panel updates to show the new creature while the left panel continues showing the pinned creature.
|
||||||
8. **Given** a creature is pinned to the left panel, **When** the user clicks the unpin button on the left panel, **Then** the left panel is removed and only the right panel remains.
|
8. **Given** a creature is pinned to the left panel, **When** the user clicks the unpin button on the left panel, **Then** the left panel is removed and only the right panel remains.
|
||||||
9. **Given** the user is on a small screen or mobile viewport, **When** the stat block panel is displayed, **Then** the pin button is not visible.
|
9. **Given** the user is on a small screen or mobile viewport, **When** the stat block panel is displayed, **Then** the pin button is not visible.
|
||||||
10. **Given** both pinned (left) and browse (right) panels are open, **When** the user folds the right panel, **Then** the left pinned panel remains visible and the right panel collapses to a tab.
|
10. **Given** both pinned (left) and browse (right) panels are open, **When** the user collapses the right panel, **Then** the left pinned panel remains visible and the right panel reduces to a tab.
|
||||||
11. **Given** the right panel is folded and the left panel is pinned, **When** the user unfolds the right panel, **Then** it slides back showing the last browsed creature.
|
11. **Given** the right panel is collapsed and the left panel is pinned, **When** the user expands the right panel, **Then** it slides back showing the last browsed creature.
|
||||||
|
|
||||||
### Edge Cases
|
### Edge Cases
|
||||||
|
|
||||||
- User pins a creature and then that creature is removed from the encounter: the pinned panel continues displaying the creature's stat block (data is already loaded).
|
- User pins a creature and then that creature is removed from the encounter: the pinned panel continues displaying the creature's stat block (data is already loaded).
|
||||||
- User folds the panel and then the active combatant changes (auto-show logic on desktop): the panel stays folded but updates the selected creature internally; unfolding shows the current active combatant's stat block. The fold state is respected — advancing turns does not override a user-chosen fold.
|
- Active combatant changes while panel is open or collapsed: advancing turns does not auto-show or update the stat block panel. The panel only changes when the user explicitly clicks a book icon. If the panel is collapsed, it stays collapsed.
|
||||||
- Viewport resized from wide to narrow while a creature is pinned: the pinned (left) panel is hidden and the pin button disappears; resizing back to wide restores the pinned panel.
|
- Viewport resized from wide to narrow while a creature is pinned: the pinned (left) panel is hidden and the pin button disappears; resizing back to wide restores the pinned panel.
|
||||||
- User is in bulk import mode and tries to fold: the fold/unfold behavior applies to the bulk import panel identically.
|
- User is in bulk import mode and tries to collapse: the collapse/expand behavior applies to the bulk import panel identically.
|
||||||
- Panel showing a source fetch prompt: the pin button is hidden.
|
- Panel showing a source fetch prompt: the pin button is hidden.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -276,7 +280,7 @@ As a DM with a creature pinned, I want to fold the right (browse) panel independ
|
|||||||
- **Queued Creature**: Transient UI-only state representing a bestiary creature selected for batch-add, containing the creature reference and a count (1+). Not persisted.
|
- **Queued Creature**: Transient UI-only state representing a bestiary creature selected for batch-add, containing the creature reference and a count (1+). Not persisted.
|
||||||
- **Bulk Import Operation**: Tracks total sources, completed count, failed count, and current status (idle / loading / complete / partial-failure).
|
- **Bulk Import Operation**: Tracks total sources, completed count, failed count, and current status (idle / loading / complete / partial-failure).
|
||||||
- **Toast Notification**: Lightweight custom UI element at bottom-center of screen with text, optional progress bar, and optional dismiss button.
|
- **Toast Notification**: Lightweight custom UI element at bottom-center of screen with text, optional progress bar, and optional dismiss button.
|
||||||
- **Panel State**: Represents whether a stat block panel is expanded, folded, or absent. The browse (right) and pinned (left) panels each have independent state.
|
- **Panel State**: Represents whether a stat block panel is expanded, collapsed, or absent. The browse (right) and pinned (left) panels each have independent state.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -295,7 +299,7 @@ As a DM with a creature pinned, I want to fold the right (browse) panel independ
|
|||||||
- **SC-011**: Users can load all bestiary sources with a single confirmation action; real-time progress is visible during the operation.
|
- **SC-011**: Users can load all bestiary sources with a single confirmation action; real-time progress is visible during the operation.
|
||||||
- **SC-012**: Already-cached sources are skipped during bulk import, reducing redundant data transfer on repeat imports.
|
- **SC-012**: Already-cached sources are skipped during bulk import, reducing redundant data transfer on repeat imports.
|
||||||
- **SC-013**: The rest of the app remains fully interactive during the bulk import operation.
|
- **SC-013**: The rest of the app remains fully interactive during the bulk import operation.
|
||||||
- **SC-014**: Users can fold the stat block panel in a single click and unfold it in a single click, with the transition completing in under 300ms.
|
- **SC-014**: Users can collapse the stat block panel in a single click and expand it in a single click, with the transition completing in under 300ms.
|
||||||
- **SC-015**: Users can pin a creature and browse a different creature's stat block simultaneously, without any additional navigation steps.
|
- **SC-015**: Users can pin a creature and browse a different creature's stat block simultaneously, without any additional navigation steps.
|
||||||
- **SC-016**: The pinned panel is never visible on screens narrower than the dual-panel breakpoint, ensuring mobile usability is not degraded.
|
- **SC-016**: The pinned panel is never visible on screens narrower than the dual-panel breakpoint, ensuring mobile usability is not degraded.
|
||||||
- **SC-017**: All fold/unfold and pin/unpin interactions are discoverable through visible button controls without requiring documentation or tooltips.
|
- **SC-017**: All collapse/expand and pin/unpin interactions are discoverable through visible button controls without requiring documentation or tooltips.
|
||||||
|
|||||||
Reference in New Issue
Block a user