diff --git a/apps/web/src/adapters/__tests__/pf2e-bestiary-adapter.test.ts b/apps/web/src/adapters/__tests__/pf2e-bestiary-adapter.test.ts
index 68ca570..8defbfc 100644
--- a/apps/web/src/adapters/__tests__/pf2e-bestiary-adapter.test.ts
+++ b/apps/web/src/adapters/__tests__/pf2e-bestiary-adapter.test.ts
@@ -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: "
This sword blazes with fire.
",
+ },
+ },
+ },
+ ],
+ }),
+ );
+ 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: "Restores 3d8+10 Hit Points.
",
+ },
+ 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: "A scroll.
" },
+ 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: "A wand.
" },
+ 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: "Grants fire resistance 5.
",
+ },
+ },
+ },
+ ],
+ }),
+ );
+ 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: "A deadly poison.
",
+ },
+ 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: "Full plate made of adamantine.
",
+ },
+ 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:
+ "Gain @UUID[Compendium.pf2e.conditionitems.Item.Quickened]{quickened} for 1 minute.
",
+ },
+ category: "potion",
+ },
+ },
+ ],
+ }),
+ );
+ const desc = creature.equipment?.[0]?.description;
+ expect(desc).toBe("Gain quickened for 1 minute.");
+ expect(desc).not.toContain("@UUID");
+ });
+ });
+
describe("spellcasting normalization", () => {
it("normalizes prepared spells by rank", () => {
const creature = normalizeFoundryCreature(
diff --git a/apps/web/src/adapters/bestiary-cache.ts b/apps/web/src/adapters/bestiary-cache.ts
index cc0001b..8c9152f 100644
--- a/apps/web/src/adapters/bestiary-cache.ts
+++ b/apps/web/src/adapters/bestiary-cache.ts
@@ -3,8 +3,8 @@ import { type IDBPDatabase, openDB } from "idb";
const DB_NAME = "initiative-bestiary";
const STORE_NAME = "sources";
-// v6 (2026-04-09): SpellReference per-spell data added; old caches are cleared
-const DB_VERSION = 6;
+// v7 (2026-04-10): Equipment items added to PF2e creatures; old caches are cleared
+const DB_VERSION = 7;
interface CachedSourceInfo {
readonly sourceCode: string;
diff --git a/apps/web/src/adapters/pf2e-bestiary-adapter.ts b/apps/web/src/adapters/pf2e-bestiary-adapter.ts
index a211e24..47d39bb 100644
--- a/apps/web/src/adapters/pf2e-bestiary-adapter.ts
+++ b/apps/web/src/adapters/pf2e-bestiary-adapter.ts
@@ -1,5 +1,6 @@
import type {
CreatureId,
+ EquipmentItem,
Pf2eCreature,
SpellcastingBlock,
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 = {
tiny: "tiny",
sm: "small",
@@ -633,6 +701,14 @@ export function normalizeFoundryCreature(
),
abilitiesBot: orUndefined(actionsByCategory(items, "offensive")),
spellcasting: orUndefined(normalizeSpellcasting(items)),
+ items:
+ items
+ .filter(isMundaneItem)
+ .map((i) => i.name)
+ .join(", ") || undefined,
+ equipment: orUndefined(
+ items.filter(isDetailedEquipment).map(normalizeEquipmentItem),
+ ),
};
}
diff --git a/apps/web/src/components/__tests__/equipment-detail-popover.test.tsx b/apps/web/src/components/__tests__/equipment-detail-popover.test.tsx
new file mode 100644
index 0000000..888e29f
--- /dev/null
+++ b/apps/web/src/components/__tests__/equipment-detail-popover.test.tsx
@@ -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(
+ {}}
+ />,
+ );
+ 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(
+ {}}
+ />,
+ );
+ expect(screen.getByText(SCROLL_SPELL_REGEX)).toBeInTheDocument();
+ });
+
+ it("calls onClose when Escape is pressed", () => {
+ const onClose = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.keyDown(document, { key: "Escape" });
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("uses the dialog role with the item name as label", () => {
+ render(
+ {}}
+ />,
+ );
+ expect(
+ screen.getByRole("dialog", {
+ name: DIALOG_LABEL_REGEX,
+ }),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/components/__tests__/pf2e-stat-block.test.tsx b/apps/web/src/components/__tests__/pf2e-stat-block.test.tsx
index 59f6cb0..100f5f2 100644
--- a/apps/web/src/components/__tests__/pf2e-stat-block.test.tsx
+++ b/apps/web/src/components/__tests__/pf2e-stat-block.test.tsx
@@ -31,6 +31,7 @@ const RK_DC_25_REGEX = /DC 25/;
const RK_HUMANOID_SOCIETY_REGEX = /Humanoid \(Society\)/;
const RK_UNDEAD_RELIGION_REGEX = /Undead \(Religion\)/;
const RK_BEAST_SKILLS_REGEX = /Beast \(Arcana\/Nature\)/;
+const SCROLL_NAME_REGEX = /Scroll of Teleport/;
const GOBLIN_WARRIOR: Pf2eCreature = {
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", () => {
const SPELLCASTER: Pf2eCreature = {
...NAUNET,
diff --git a/apps/web/src/components/detail-popover.tsx b/apps/web/src/components/detail-popover.tsx
new file mode 100644
index 0000000..33f45f0
--- /dev/null
+++ b/apps/web/src/components/detail-popover.tsx
@@ -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) {
+ const ref = useRef(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 (
+
+ {children}
+
+ );
+}
+
+function MobileSheet({
+ onClose,
+ ariaLabel,
+ children,
+}: Readonly>) {
+ 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 (
+
+ );
+}
+
+export function DetailPopover({
+ anchorRect,
+ onClose,
+ ariaLabel,
+ children,
+}: Readonly) {
+ 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 ? (
+
+ {children}
+
+ ) : (
+
+ {children}
+
+ );
+ return createPortal(content, document.body);
+}
diff --git a/apps/web/src/components/equipment-detail-popover.tsx b/apps/web/src/components/equipment-detail-popover.tsx
new file mode 100644
index 0000000..203ca5d
--- /dev/null
+++ b/apps/web/src/components/equipment-detail-popover.tsx
@@ -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 (
+
+
{item.name}
+ {item.traits && item.traits.length > 0 && (
+
+ {item.traits.map((t) => (
+
+ {t}
+
+ ))}
+
+ )}
+
+
+ Level {item.level}
+
+ {item.category ? (
+
+ Category{" "}
+ {item.category.charAt(0).toUpperCase() + item.category.slice(1)}
+
+ ) : null}
+ {item.spellName ? (
+
+ Spell {item.spellName}
+ {item.spellRank === undefined ? "" : ` (Rank ${item.spellRank})`}
+
+ ) : null}
+
+ {item.description ? (
+
+ {item.description}
+
+ ) : (
+
+ No description available.
+
+ )}
+
+ );
+}
+
+export function EquipmentDetailPopover({
+ item,
+ anchorRect,
+ onClose,
+}: Readonly) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/components/pf2e-stat-block.tsx b/apps/web/src/components/pf2e-stat-block.tsx
index a4daf49..d166736 100644
--- a/apps/web/src/components/pf2e-stat-block.tsx
+++ b/apps/web/src/components/pf2e-stat-block.tsx
@@ -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 { useCallback, useRef, useState } from "react";
+import { EquipmentDetailPopover } from "./equipment-detail-popover.js";
import { SpellDetailPopover } from "./spell-detail-popover.js";
import {
PropertyLine,
@@ -102,6 +107,35 @@ function SpellListLine({
);
}
+interface EquipmentLinkProps {
+ readonly item: EquipmentItem;
+ readonly onOpen: (item: EquipmentItem, rect: DOMRect) => void;
+}
+
+function EquipmentLink({ item, onOpen }: Readonly) {
+ const ref = useRef(null);
+ const handleClick = useCallback(() => {
+ if (!item.description) return;
+ const rect = ref.current?.getBoundingClientRect();
+ if (rect) onOpen(item, rect);
+ }, [item, onOpen]);
+
+ if (!item.description) {
+ return {item.name};
+ }
+
+ return (
+
+ );
+}
+
export function Pf2eStatBlock({ creature }: Readonly) {
const [openSpell, setOpenSpell] = useState<{
spell: SpellReference;
@@ -112,6 +146,15 @@ export function Pf2eStatBlock({ creature }: Readonly) {
[],
);
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);
@@ -256,6 +299,19 @@ export function Pf2eStatBlock({ creature }: Readonly) {
))}
>
)}
+ {creature.equipment && creature.equipment.length > 0 && (
+ <>
+
+ Equipment
+
+ {creature.equipment.map((item) => (
+
+
+
+ ))}
+
+ >
+ )}
{openSpell ? (
) {
onClose={handleCloseSpell}
/>
) : null}
+ {openEquipment ? (
+
+ ) : null}
);
}
diff --git a/apps/web/src/components/spell-detail-popover.tsx b/apps/web/src/components/spell-detail-popover.tsx
index d0bc487..0bd31ff 100644
--- a/apps/web/src/components/spell-detail-popover.tsx
+++ b/apps/web/src/components/spell-detail-popover.tsx
@@ -1,9 +1,5 @@
import type { ActivityCost, SpellReference } from "@initiative/domain";
-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";
+import { DetailPopover } from "./detail-popover.js";
import { ActivityIcon } from "./stat-block-parts.js";
interface SpellDetailPopoverProps {
@@ -178,119 +174,18 @@ function SpellDetailContent({ spell }: Readonly<{ spell: SpellReference }>) {
);
}
-function DesktopPopover({
- spell,
- anchorRect,
- onClose,
-}: Readonly) {
- const ref = useRef(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 (
-
-
-
- );
-}
-
-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 (
-
- );
-}
-
export function SpellDetailPopover({
spell,
anchorRect,
onClose,
}: Readonly) {
- const [isDesktop, setIsDesktop] = useState(
- () => globalThis.matchMedia("(min-width: 1024px)").matches,
+ return (
+
+
+
);
-
- 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 ? (
-
- ) : (
-
- );
- return createPortal(content, document.body);
}
diff --git a/packages/domain/src/creature-types.ts b/packages/domain/src/creature-types.ts
index 548bf8e..59e4ace 100644
--- a/packages/domain/src/creature-types.ts
+++ b/packages/domain/src/creature-types.ts
@@ -86,6 +86,19 @@ export interface SpellReference {
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 {
readonly uses: number;
readonly each: boolean;
@@ -201,6 +214,7 @@ export interface Pf2eCreature {
readonly abilitiesMid?: readonly TraitBlock[];
readonly abilitiesBot?: readonly TraitBlock[];
readonly spellcasting?: readonly SpellcastingBlock[];
+ readonly equipment?: readonly EquipmentItem[];
}
export type AnyCreature = Creature | Pf2eCreature;
diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts
index bfd37a5..c455dc2 100644
--- a/packages/domain/src/index.ts
+++ b/packages/domain/src/index.ts
@@ -33,6 +33,7 @@ export {
type CreatureId,
creatureId,
type DailySpells,
+ type EquipmentItem,
type LegendaryBlock,
type Pf2eBestiaryIndex,
type Pf2eBestiaryIndexEntry,