Compare commits

...

1 Commits
main ... 0.9.34

Author SHA1 Message Date
Lukas
e44e56b09b Add PF2e equipment display with detail popovers in stat blocks
All checks were successful
CI / check (push) Successful in 2m31s
CI / build-image (push) Successful in 19s
Extract shared DetailPopover shell from spell popovers. Normalize
weapon/consumable/equipment/armor items from Foundry data into
mundane (Items line) and detailed (Equipment section with clickable
popovers). Scrolls/wands show embedded spell info. Bump IDB cache v7.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:21:11 +02:00
11 changed files with 840 additions and 117 deletions

View File

@@ -541,6 +541,288 @@ describe("normalizeFoundryCreature", () => {
}); });
}); });
describe("equipment normalization", () => {
it("normalizes a weapon with traits and description", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "w1",
name: "Flaming Longsword",
type: "weapon",
system: {
level: { value: 5 },
traits: { value: ["magical", "fire"] },
description: {
value: "<p>This sword blazes with fire.</p>",
},
},
},
],
}),
);
expect(creature.equipment).toHaveLength(1);
const item = creature.equipment?.[0];
expect(item?.name).toBe("Flaming Longsword");
expect(item?.level).toBe(5);
expect(item?.traits).toEqual(["magical", "fire"]);
expect(item?.description).toBe("This sword blazes with fire.");
});
it("normalizes a consumable potion with description", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "c1",
name: "Healing Potion (Moderate)",
type: "consumable",
system: {
level: { value: 6 },
traits: { value: ["consumable", "healing", "magical"] },
description: {
value: "<p>Restores 3d8+10 Hit Points.</p>",
},
category: "potion",
},
},
],
}),
);
expect(creature.equipment).toHaveLength(1);
const item = creature.equipment?.[0];
expect(item?.name).toBe("Healing Potion (Moderate)");
expect(item?.category).toBe("potion");
expect(item?.description).toBe("Restores 3d8+10 Hit Points.");
});
it("extracts scroll embedded spell name and rank", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "s1",
name: "Scroll of Teleport (Rank 6)",
type: "consumable",
system: {
level: { value: 11 },
traits: { value: ["consumable", "magical", "scroll"] },
description: { value: "<p>A scroll.</p>" },
category: "scroll",
spell: {
name: "Teleport",
system: { level: { value: 6 } },
},
},
},
],
}),
);
const item = creature.equipment?.[0];
expect(item?.spellName).toBe("Teleport");
expect(item?.spellRank).toBe(6);
});
it("extracts wand embedded spell name and rank", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "w1",
name: "Wand of Dispel Magic (Rank 2)",
type: "consumable",
system: {
level: { value: 5 },
traits: { value: ["consumable", "magical", "wand"] },
description: { value: "<p>A wand.</p>" },
category: "wand",
spell: {
name: "Dispel Magic",
system: { level: { value: 2 } },
},
},
},
],
}),
);
const item = creature.equipment?.[0];
expect(item?.spellName).toBe("Dispel Magic");
expect(item?.spellRank).toBe(2);
});
it("filters magical equipment into equipment field", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "e1",
name: "Ring of Energy Resistance (Fire)",
type: "equipment",
system: {
level: { value: 6 },
traits: { value: ["magical", "invested"] },
description: {
value: "<p>Grants fire resistance 5.</p>",
},
},
},
],
}),
);
expect(creature.equipment).toHaveLength(1);
expect(creature.equipment?.[0]?.name).toBe(
"Ring of Energy Resistance (Fire)",
);
expect(creature.items).toBeUndefined();
});
it("filters mundane items into items string", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "w1",
name: "Longsword",
type: "weapon",
system: {
level: { value: 0 },
traits: { value: [] },
description: { value: "" },
},
},
{
_id: "a1",
name: "Leather Armor",
type: "armor",
system: {
level: { value: 0 },
traits: { value: [] },
description: { value: "" },
},
},
],
}),
);
expect(creature.items).toBe("Longsword, Leather Armor");
expect(creature.equipment).toBeUndefined();
});
it("omits equipment when no detailed items exist", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "w1",
name: "Dagger",
type: "weapon",
system: {
level: { value: 0 },
traits: { value: [] },
description: { value: "" },
},
},
],
}),
);
expect(creature.equipment).toBeUndefined();
});
it("omits items when no mundane items exist", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "c1",
name: "Giant Wasp Venom",
type: "consumable",
system: {
level: { value: 7 },
traits: { value: ["consumable", "poison"] },
description: {
value: "<p>A deadly poison.</p>",
},
category: "poison",
},
},
],
}),
);
expect(creature.items).toBeUndefined();
expect(creature.equipment).toHaveLength(1);
});
it("includes armor with special material in equipment", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "a1",
name: "Adamantine Full Plate",
type: "armor",
system: {
level: { value: 0 },
traits: { value: [] },
description: {
value: "<p>Full plate made of adamantine.</p>",
},
material: { type: "adamantine", grade: "standard" },
},
},
],
}),
);
expect(creature.equipment).toHaveLength(1);
expect(creature.equipment?.[0]?.name).toBe("Adamantine Full Plate");
});
it("excludes mundane armor from equipment (goes to items)", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "a1",
name: "Chain Mail",
type: "armor",
system: {
level: { value: 0 },
traits: { value: [] },
description: { value: "" },
},
},
],
}),
);
expect(creature.equipment).toBeUndefined();
expect(creature.items).toBe("Chain Mail");
});
it("strips Foundry HTML tags from equipment descriptions", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "c1",
name: "Potion of Speed",
type: "consumable",
system: {
level: { value: 10 },
traits: { value: ["consumable", "magical"] },
description: {
value:
"<p>Gain @UUID[Compendium.pf2e.conditionitems.Item.Quickened]{quickened} for 1 minute.</p>",
},
category: "potion",
},
},
],
}),
);
const desc = creature.equipment?.[0]?.description;
expect(desc).toBe("Gain quickened for 1 minute.");
expect(desc).not.toContain("@UUID");
});
});
describe("spellcasting normalization", () => { describe("spellcasting normalization", () => {
it("normalizes prepared spells by rank", () => { it("normalizes prepared spells by rank", () => {
const creature = normalizeFoundryCreature( const creature = normalizeFoundryCreature(

View File

@@ -3,8 +3,8 @@ import { type IDBPDatabase, openDB } from "idb";
const DB_NAME = "initiative-bestiary"; const DB_NAME = "initiative-bestiary";
const STORE_NAME = "sources"; const STORE_NAME = "sources";
// v6 (2026-04-09): SpellReference per-spell data added; old caches are cleared // v7 (2026-04-10): Equipment items added to PF2e creatures; old caches are cleared
const DB_VERSION = 6; const DB_VERSION = 7;
interface CachedSourceInfo { interface CachedSourceInfo {
readonly sourceCode: string; readonly sourceCode: string;

View File

@@ -1,5 +1,6 @@
import type { import type {
CreatureId, CreatureId,
EquipmentItem,
Pf2eCreature, Pf2eCreature,
SpellcastingBlock, SpellcastingBlock,
SpellReference, SpellReference,
@@ -114,6 +115,73 @@ interface SpellSystem {
>; >;
} }
interface ConsumableSystem {
level?: { value: number };
traits?: { value: string[] };
description?: { value: string };
category?: string;
spell?: {
name: string;
system?: { level?: { value: number } };
} | null;
}
const EQUIPMENT_TYPES = new Set(["weapon", "consumable", "equipment", "armor"]);
/** Items shown in the Equipment section with popovers. */
function isDetailedEquipment(item: RawFoundryItem): boolean {
if (!EQUIPMENT_TYPES.has(item.type)) return false;
const sys = item.system;
const level = (sys.level as { value: number } | undefined)?.value ?? 0;
const traits = (sys.traits as { value: string[] } | undefined)?.value ?? [];
// All consumables are tactically relevant (potions, scrolls, poisons, etc.)
if (item.type === "consumable") return true;
// Magical/invested items
if (traits.includes("magical") || traits.includes("invested")) return true;
// Special material armor/equipment
const material = sys.material as { type: string | null } | undefined;
if (material?.type) return true;
// Higher-level items
if (level > 0) return true;
return false;
}
/** Items shown on the "Items" line as plain names. */
function isMundaneItem(item: RawFoundryItem): boolean {
return EQUIPMENT_TYPES.has(item.type) && !isDetailedEquipment(item);
}
function normalizeEquipmentItem(item: RawFoundryItem): EquipmentItem {
const sys = item.system;
const level = (sys.level as { value: number } | undefined)?.value ?? 0;
const traits = (sys.traits as { value: string[] } | undefined)?.value;
const rawDesc = (sys.description as { value: string } | undefined)?.value;
const description = rawDesc
? stripFoundryTags(rawDesc) || undefined
: undefined;
const category = sys.category as string | undefined;
let spellName: string | undefined;
let spellRank: number | undefined;
if (item.type === "consumable") {
const spell = (sys as unknown as ConsumableSystem).spell;
if (spell) {
spellName = spell.name;
spellRank = spell.system?.level?.value;
}
}
return {
name: item.name,
level,
category: category || undefined,
traits: traits && traits.length > 0 ? traits : undefined,
description,
spellName,
spellRank,
};
}
const SIZE_MAP: Record<string, string> = { const SIZE_MAP: Record<string, string> = {
tiny: "tiny", tiny: "tiny",
sm: "small", sm: "small",
@@ -633,6 +701,14 @@ export function normalizeFoundryCreature(
), ),
abilitiesBot: orUndefined(actionsByCategory(items, "offensive")), abilitiesBot: orUndefined(actionsByCategory(items, "offensive")),
spellcasting: orUndefined(normalizeSpellcasting(items)), spellcasting: orUndefined(normalizeSpellcasting(items)),
items:
items
.filter(isMundaneItem)
.map((i) => i.name)
.join(", ") || undefined,
equipment: orUndefined(
items.filter(isDetailedEquipment).map(normalizeEquipmentItem),
),
}; };
} }

View File

@@ -0,0 +1,107 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import type { EquipmentItem } from "@initiative/domain";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EquipmentDetailPopover } from "../equipment-detail-popover.js";
afterEach(cleanup);
const POISON: EquipmentItem = {
name: "Giant Wasp Venom",
level: 7,
category: "poison",
traits: ["consumable", "poison", "injury"],
description: "A deadly poison extracted from giant wasps.",
};
const SCROLL: EquipmentItem = {
name: "Scroll of Teleport",
level: 11,
category: "scroll",
traits: ["consumable", "magical", "scroll"],
description: "A scroll containing Teleport.",
spellName: "Teleport",
spellRank: 6,
};
const ANCHOR: DOMRect = new DOMRect(100, 100, 50, 20);
const SCROLL_SPELL_REGEX = /Teleport \(Rank 6\)/;
const DIALOG_LABEL_REGEX = /Equipment details: Giant Wasp Venom/;
beforeEach(() => {
Object.defineProperty(globalThis, "matchMedia", {
writable: true,
configurable: true,
value: vi.fn().mockImplementation(() => ({
matches: true,
media: "(min-width: 1024px)",
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
describe("EquipmentDetailPopover", () => {
it("renders item name, level, traits, and description", () => {
render(
<EquipmentDetailPopover
item={POISON}
anchorRect={ANCHOR}
onClose={() => {}}
/>,
);
expect(screen.getByText("Giant Wasp Venom")).toBeInTheDocument();
expect(screen.getByText("7")).toBeInTheDocument();
expect(screen.getByText("consumable")).toBeInTheDocument();
expect(screen.getByText("poison")).toBeInTheDocument();
expect(screen.getByText("injury")).toBeInTheDocument();
expect(
screen.getByText("A deadly poison extracted from giant wasps."),
).toBeInTheDocument();
});
it("renders scroll/wand spell info", () => {
render(
<EquipmentDetailPopover
item={SCROLL}
anchorRect={ANCHOR}
onClose={() => {}}
/>,
);
expect(screen.getByText(SCROLL_SPELL_REGEX)).toBeInTheDocument();
});
it("calls onClose when Escape is pressed", () => {
const onClose = vi.fn();
render(
<EquipmentDetailPopover
item={POISON}
anchorRect={ANCHOR}
onClose={onClose}
/>,
);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
});
it("uses the dialog role with the item name as label", () => {
render(
<EquipmentDetailPopover
item={POISON}
anchorRect={ANCHOR}
onClose={() => {}}
/>,
);
expect(
screen.getByRole("dialog", {
name: DIALOG_LABEL_REGEX,
}),
).toBeInTheDocument();
});
});

View File

@@ -31,6 +31,7 @@ const RK_DC_25_REGEX = /DC 25/;
const RK_HUMANOID_SOCIETY_REGEX = /Humanoid \(Society\)/; const RK_HUMANOID_SOCIETY_REGEX = /Humanoid \(Society\)/;
const RK_UNDEAD_RELIGION_REGEX = /Undead \(Religion\)/; const RK_UNDEAD_RELIGION_REGEX = /Undead \(Religion\)/;
const RK_BEAST_SKILLS_REGEX = /Beast \(Arcana\/Nature\)/; const RK_BEAST_SKILLS_REGEX = /Beast \(Arcana\/Nature\)/;
const SCROLL_NAME_REGEX = /Scroll of Teleport/;
const GOBLIN_WARRIOR: Pf2eCreature = { const GOBLIN_WARRIOR: Pf2eCreature = {
system: "pf2e", system: "pf2e",
@@ -338,6 +339,79 @@ describe("Pf2eStatBlock", () => {
}); });
}); });
describe("equipment section", () => {
const CREATURE_WITH_EQUIPMENT: Pf2eCreature = {
...GOBLIN_WARRIOR,
id: creatureId("test:equipped"),
name: "Equipped NPC",
items: "longsword, leather armor",
equipment: [
{
name: "Giant Wasp Venom",
level: 7,
category: "poison",
traits: ["consumable", "poison"],
description: "A deadly poison extracted from giant wasps.",
},
{
name: "Scroll of Teleport",
level: 11,
category: "scroll",
traits: ["consumable", "magical", "scroll"],
description: "A scroll containing Teleport.",
spellName: "Teleport",
spellRank: 6,
},
{
name: "Plain Talisman",
level: 1,
traits: ["magical"],
},
],
};
it("renders Equipment section with item names", () => {
renderStatBlock(CREATURE_WITH_EQUIPMENT);
expect(
screen.getByRole("heading", { name: "Equipment" }),
).toBeInTheDocument();
expect(screen.getByText("Giant Wasp Venom")).toBeInTheDocument();
});
it("renders scroll name as-is from Foundry data", () => {
renderStatBlock(CREATURE_WITH_EQUIPMENT);
expect(screen.getByText(SCROLL_NAME_REGEX)).toBeInTheDocument();
});
it("does not render Equipment section when creature has no equipment", () => {
renderStatBlock(GOBLIN_WARRIOR);
expect(
screen.queryByRole("heading", { name: "Equipment" }),
).not.toBeInTheDocument();
});
it("renders equipment items with descriptions as clickable buttons", () => {
renderStatBlock(CREATURE_WITH_EQUIPMENT);
expect(
screen.getByRole("button", { name: "Giant Wasp Venom" }),
).toBeInTheDocument();
});
it("renders equipment items without descriptions as plain text", () => {
renderStatBlock(CREATURE_WITH_EQUIPMENT);
expect(
screen.queryByRole("button", { name: "Plain Talisman" }),
).not.toBeInTheDocument();
expect(screen.getByText("Plain Talisman")).toBeInTheDocument();
});
it("renders Items line with mundane item names", () => {
renderStatBlock(CREATURE_WITH_EQUIPMENT);
expect(screen.getByText("Items")).toBeInTheDocument();
expect(screen.getByText("longsword, leather armor")).toBeInTheDocument();
});
});
describe("clickable spells", () => { describe("clickable spells", () => {
const SPELLCASTER: Pf2eCreature = { const SPELLCASTER: Pf2eCreature = {
...NAUNET, ...NAUNET,

View File

@@ -0,0 +1,141 @@
import type { ReactNode } from "react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useClickOutside } from "../hooks/use-click-outside.js";
import { useSwipeToDismissDown } from "../hooks/use-swipe-to-dismiss.js";
import { cn } from "../lib/utils.js";
interface DetailPopoverProps {
readonly anchorRect: DOMRect;
readonly onClose: () => void;
readonly ariaLabel: string;
readonly children: ReactNode;
}
function DesktopPanel({
anchorRect,
onClose,
ariaLabel,
children,
}: Readonly<DetailPopoverProps>) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const popover = el.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
const vh = document.documentElement.clientHeight;
// Prefer placement to the LEFT of the anchor (panel is on the right edge)
let left = anchorRect.left - popover.width - 8;
if (left < 8) {
left = anchorRect.right + 8;
}
if (left + popover.width > vw - 8) {
left = vw - popover.width - 8;
}
let top = anchorRect.top;
if (top + popover.height > vh - 8) {
top = vh - popover.height - 8;
}
if (top < 8) top = 8;
setPos({ top, left });
}, [anchorRect]);
useClickOutside(ref, onClose);
return (
<div
ref={ref}
className="card-glow fixed z-50 max-h-[calc(100vh-16px)] w-80 max-w-[calc(100vw-16px)] overflow-y-auto rounded-lg border border-border bg-card p-4 shadow-lg"
style={pos ? { top: pos.top, left: pos.left } : { visibility: "hidden" }}
role="dialog"
aria-label={ariaLabel}
>
{children}
</div>
);
}
function MobileSheet({
onClose,
ariaLabel,
children,
}: Readonly<Omit<DetailPopoverProps, "anchorRect">>) {
const { offsetY, isSwiping, handlers } = useSwipeToDismissDown(onClose);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
return (
<div className="fixed inset-0 z-50">
<button
type="button"
className="fade-in absolute inset-0 animate-in bg-black/50"
onClick={onClose}
aria-label="Close details"
/>
<div
className={cn(
"panel-glow absolute right-0 bottom-0 left-0 max-h-[80vh] rounded-t-2xl border-border border-t bg-card",
!isSwiping && "animate-slide-in-bottom",
)}
style={
isSwiping ? { transform: `translateY(${offsetY}px)` } : undefined
}
{...handlers}
role="dialog"
aria-label={ariaLabel}
>
<div className="flex justify-center pt-2 pb-1">
<div className="h-1 w-10 rounded-full bg-muted-foreground/40" />
</div>
<div className="max-h-[calc(80vh-24px)] overflow-y-auto p-4">
{children}
</div>
</div>
</div>
);
}
export function DetailPopover({
anchorRect,
onClose,
ariaLabel,
children,
}: Readonly<DetailPopoverProps>) {
const [isDesktop, setIsDesktop] = useState(
() => globalThis.matchMedia("(min-width: 1024px)").matches,
);
useEffect(() => {
const mq = globalThis.matchMedia("(min-width: 1024px)");
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
// Portal to document.body to escape any CSS transforms on ancestors
// (the side panel uses translate-x for collapse animation, which would
// otherwise become the containing block for fixed-positioned children).
const content = isDesktop ? (
<DesktopPanel
anchorRect={anchorRect}
onClose={onClose}
ariaLabel={ariaLabel}
>
{children}
</DesktopPanel>
) : (
<MobileSheet onClose={onClose} ariaLabel={ariaLabel}>
{children}
</MobileSheet>
);
return createPortal(content, document.body);
}

View File

@@ -0,0 +1,70 @@
import type { EquipmentItem } from "@initiative/domain";
import { DetailPopover } from "./detail-popover.js";
interface EquipmentDetailPopoverProps {
readonly item: EquipmentItem;
readonly anchorRect: DOMRect;
readonly onClose: () => void;
}
function EquipmentDetailContent({ item }: Readonly<{ item: EquipmentItem }>) {
return (
<div className="space-y-2 text-sm">
<h3 className="font-bold text-lg text-stat-heading">{item.name}</h3>
{item.traits && item.traits.length > 0 && (
<div className="flex flex-wrap gap-1">
{item.traits.map((t) => (
<span
key={t}
className="rounded border border-border bg-card px-1.5 py-0.5 text-foreground text-xs"
>
{t}
</span>
))}
</div>
)}
<div className="space-y-0.5 text-xs">
<div>
<span className="font-semibold">Level</span> {item.level}
</div>
{item.category ? (
<div>
<span className="font-semibold">Category</span>{" "}
{item.category.charAt(0).toUpperCase() + item.category.slice(1)}
</div>
) : null}
{item.spellName ? (
<div>
<span className="font-semibold">Spell</span> {item.spellName}
{item.spellRank === undefined ? "" : ` (Rank ${item.spellRank})`}
</div>
) : null}
</div>
{item.description ? (
<p className="whitespace-pre-line text-foreground">
{item.description}
</p>
) : (
<p className="text-muted-foreground italic">
No description available.
</p>
)}
</div>
);
}
export function EquipmentDetailPopover({
item,
anchorRect,
onClose,
}: Readonly<EquipmentDetailPopoverProps>) {
return (
<DetailPopover
anchorRect={anchorRect}
onClose={onClose}
ariaLabel={`Equipment details: ${item.name}`}
>
<EquipmentDetailContent item={item} />
</DetailPopover>
);
}

View File

@@ -1,6 +1,11 @@
import type { Pf2eCreature, SpellReference } from "@initiative/domain"; import type {
EquipmentItem,
Pf2eCreature,
SpellReference,
} from "@initiative/domain";
import { formatInitiativeModifier, recallKnowledge } from "@initiative/domain"; import { formatInitiativeModifier, recallKnowledge } from "@initiative/domain";
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { EquipmentDetailPopover } from "./equipment-detail-popover.js";
import { SpellDetailPopover } from "./spell-detail-popover.js"; import { SpellDetailPopover } from "./spell-detail-popover.js";
import { import {
PropertyLine, PropertyLine,
@@ -102,6 +107,35 @@ function SpellListLine({
); );
} }
interface EquipmentLinkProps {
readonly item: EquipmentItem;
readonly onOpen: (item: EquipmentItem, rect: DOMRect) => void;
}
function EquipmentLink({ item, onOpen }: Readonly<EquipmentLinkProps>) {
const ref = useRef<HTMLButtonElement>(null);
const handleClick = useCallback(() => {
if (!item.description) return;
const rect = ref.current?.getBoundingClientRect();
if (rect) onOpen(item, rect);
}, [item, onOpen]);
if (!item.description) {
return <span>{item.name}</span>;
}
return (
<button
ref={ref}
type="button"
onClick={handleClick}
className="cursor-pointer text-foreground underline decoration-dotted underline-offset-2 hover:text-hover-neutral"
>
{item.name}
</button>
);
}
export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) { export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
const [openSpell, setOpenSpell] = useState<{ const [openSpell, setOpenSpell] = useState<{
spell: SpellReference; spell: SpellReference;
@@ -112,6 +146,15 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
[], [],
); );
const handleCloseSpell = useCallback(() => setOpenSpell(null), []); const handleCloseSpell = useCallback(() => setOpenSpell(null), []);
const [openEquipment, setOpenEquipment] = useState<{
item: EquipmentItem;
rect: DOMRect;
} | null>(null);
const handleOpenEquipment = useCallback(
(item: EquipmentItem, rect: DOMRect) => setOpenEquipment({ item, rect }),
[],
);
const handleCloseEquipment = useCallback(() => setOpenEquipment(null), []);
const rk = recallKnowledge(creature.level, creature.traits); const rk = recallKnowledge(creature.level, creature.traits);
@@ -256,6 +299,19 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
))} ))}
</> </>
)} )}
{creature.equipment && creature.equipment.length > 0 && (
<>
<SectionDivider />
<h3 className="font-bold text-base text-stat-heading">Equipment</h3>
<div className="space-y-1 text-sm">
{creature.equipment.map((item) => (
<div key={item.name}>
<EquipmentLink item={item} onOpen={handleOpenEquipment} />
</div>
))}
</div>
</>
)}
{openSpell ? ( {openSpell ? (
<SpellDetailPopover <SpellDetailPopover
spell={openSpell.spell} spell={openSpell.spell}
@@ -263,6 +319,13 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
onClose={handleCloseSpell} onClose={handleCloseSpell}
/> />
) : null} ) : null}
{openEquipment ? (
<EquipmentDetailPopover
item={openEquipment.item}
anchorRect={openEquipment.rect}
onClose={handleCloseEquipment}
/>
) : null}
</div> </div>
); );
} }

View File

@@ -1,9 +1,5 @@
import type { ActivityCost, SpellReference } from "@initiative/domain"; import type { ActivityCost, SpellReference } from "@initiative/domain";
import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { DetailPopover } from "./detail-popover.js";
import { createPortal } from "react-dom";
import { useClickOutside } from "../hooks/use-click-outside.js";
import { useSwipeToDismissDown } from "../hooks/use-swipe-to-dismiss.js";
import { cn } from "../lib/utils.js";
import { ActivityIcon } from "./stat-block-parts.js"; import { ActivityIcon } from "./stat-block-parts.js";
interface SpellDetailPopoverProps { interface SpellDetailPopoverProps {
@@ -178,119 +174,18 @@ function SpellDetailContent({ spell }: Readonly<{ spell: SpellReference }>) {
); );
} }
function DesktopPopover({
spell,
anchorRect,
onClose,
}: Readonly<SpellDetailPopoverProps>) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const popover = el.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
const vh = document.documentElement.clientHeight;
// Prefer placement to the LEFT of the anchor (panel is on the right edge)
let left = anchorRect.left - popover.width - 8;
if (left < 8) {
left = anchorRect.right + 8;
}
if (left + popover.width > vw - 8) {
left = vw - popover.width - 8;
}
let top = anchorRect.top;
if (top + popover.height > vh - 8) {
top = vh - popover.height - 8;
}
if (top < 8) top = 8;
setPos({ top, left });
}, [anchorRect]);
useClickOutside(ref, onClose);
return (
<div
ref={ref}
className="card-glow fixed z-50 max-h-[calc(100vh-16px)] w-80 max-w-[calc(100vw-16px)] overflow-y-auto rounded-lg border border-border bg-card p-4 shadow-lg"
style={pos ? { top: pos.top, left: pos.left } : { visibility: "hidden" }}
role="dialog"
aria-label={`Spell details: ${spell.name}`}
>
<SpellDetailContent spell={spell} />
</div>
);
}
function MobileSheet({
spell,
onClose,
}: Readonly<{ spell: SpellReference; onClose: () => void }>) {
const { offsetY, isSwiping, handlers } = useSwipeToDismissDown(onClose);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
return (
<div className="fixed inset-0 z-50">
<button
type="button"
className="fade-in absolute inset-0 animate-in bg-black/50"
onClick={onClose}
aria-label="Close spell details"
/>
<div
className={cn(
"panel-glow absolute right-0 bottom-0 left-0 max-h-[80vh] rounded-t-2xl border-border border-t bg-card",
!isSwiping && "animate-slide-in-bottom",
)}
style={
isSwiping ? { transform: `translateY(${offsetY}px)` } : undefined
}
{...handlers}
role="dialog"
aria-label={`Spell details: ${spell.name}`}
>
<div className="flex justify-center pt-2 pb-1">
<div className="h-1 w-10 rounded-full bg-muted-foreground/40" />
</div>
<div className="max-h-[calc(80vh-24px)] overflow-y-auto p-4">
<SpellDetailContent spell={spell} />
</div>
</div>
</div>
);
}
export function SpellDetailPopover({ export function SpellDetailPopover({
spell, spell,
anchorRect, anchorRect,
onClose, onClose,
}: Readonly<SpellDetailPopoverProps>) { }: Readonly<SpellDetailPopoverProps>) {
const [isDesktop, setIsDesktop] = useState( return (
() => globalThis.matchMedia("(min-width: 1024px)").matches, <DetailPopover
anchorRect={anchorRect}
onClose={onClose}
ariaLabel={`Spell details: ${spell.name}`}
>
<SpellDetailContent spell={spell} />
</DetailPopover>
); );
useEffect(() => {
const mq = globalThis.matchMedia("(min-width: 1024px)");
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
// Portal to document.body to escape any CSS transforms on ancestors
// (the side panel uses translate-x for collapse animation, which would
// otherwise become the containing block for fixed-positioned children).
const content = isDesktop ? (
<DesktopPopover spell={spell} anchorRect={anchorRect} onClose={onClose} />
) : (
<MobileSheet spell={spell} onClose={onClose} />
);
return createPortal(content, document.body);
} }

View File

@@ -86,6 +86,19 @@ export interface SpellReference {
readonly usesPerDay?: number; readonly usesPerDay?: number;
} }
/** A carried equipment item on a PF2e creature (weapon, consumable, magic item, etc.). */
export interface EquipmentItem {
readonly name: string;
readonly level: number;
readonly category?: string;
readonly traits?: readonly string[];
readonly description?: string;
/** For scrolls/wands: the embedded spell name. */
readonly spellName?: string;
/** For scrolls/wands: the embedded spell rank. */
readonly spellRank?: number;
}
export interface DailySpells { export interface DailySpells {
readonly uses: number; readonly uses: number;
readonly each: boolean; readonly each: boolean;
@@ -201,6 +214,7 @@ export interface Pf2eCreature {
readonly abilitiesMid?: readonly TraitBlock[]; readonly abilitiesMid?: readonly TraitBlock[];
readonly abilitiesBot?: readonly TraitBlock[]; readonly abilitiesBot?: readonly TraitBlock[];
readonly spellcasting?: readonly SpellcastingBlock[]; readonly spellcasting?: readonly SpellcastingBlock[];
readonly equipment?: readonly EquipmentItem[];
} }
export type AnyCreature = Creature | Pf2eCreature; export type AnyCreature = Creature | Pf2eCreature;

View File

@@ -33,6 +33,7 @@ export {
type CreatureId, type CreatureId,
creatureId, creatureId,
type DailySpells, type DailySpells,
type EquipmentItem,
type LegendaryBlock, type LegendaryBlock,
type Pf2eBestiaryIndex, type Pf2eBestiaryIndex,
type Pf2eBestiaryIndexEntry, type Pf2eBestiaryIndexEntry,