From 1eaeecad32b3f86310da8cdee6dfd39a3950d917 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 10 Apr 2026 20:21:11 +0200 Subject: [PATCH] Add PF2e equipment display with detail popovers in stat blocks 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) --- .../__tests__/pf2e-bestiary-adapter.test.ts | 285 +++++++++++++++++- .../__tests__/strip-foundry-tags.test.ts | 10 +- apps/web/src/adapters/bestiary-cache.ts | 4 +- .../web/src/adapters/pf2e-bestiary-adapter.ts | 101 ++++++- apps/web/src/adapters/strip-foundry-tags.ts | 17 +- .../equipment-detail-popover.test.tsx | 107 +++++++ .../__tests__/pf2e-stat-block.test.tsx | 74 +++++ apps/web/src/components/detail-popover.tsx | 141 +++++++++ .../components/equipment-detail-popover.tsx | 72 +++++ apps/web/src/components/pf2e-stat-block.tsx | 65 +++- apps/web/src/components/rich-description.tsx | 20 ++ .../src/components/spell-detail-popover.tsx | 154 ++-------- apps/web/src/components/stat-block-parts.tsx | 15 +- packages/domain/src/creature-types.ts | 14 + packages/domain/src/index.ts | 1 + specs/004-bestiary/spec.md | 21 +- 16 files changed, 943 insertions(+), 158 deletions(-) create mode 100644 apps/web/src/components/__tests__/equipment-detail-popover.test.tsx create mode 100644 apps/web/src/components/detail-popover.tsx create mode 100644 apps/web/src/components/equipment-detail-popover.tsx create mode 100644 apps/web/src/components/rich-description.tsx 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..49a680c 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( @@ -597,7 +879,8 @@ describe("normalizeFoundryCreature", () => { expect(sc?.daily?.[0]?.spells.map((s) => s.name)).toEqual(["Earthquake"]); expect(sc?.daily?.[1]?.spells.map((s) => s.name)).toEqual(["Heal"]); expect(sc?.atWill?.map((s) => s.name)).toEqual(["Detect Magic"]); - expect(sc?.atWill?.[0]?.rank).toBe(1); + // Cantrip rank auto-heightens to ceil(creatureLevel / 2) = ceil(3/2) = 2 + expect(sc?.atWill?.[0]?.rank).toBe(2); }); it("normalizes innate spells with uses", () => { diff --git a/apps/web/src/adapters/__tests__/strip-foundry-tags.test.ts b/apps/web/src/adapters/__tests__/strip-foundry-tags.test.ts index 495e3d9..8292086 100644 --- a/apps/web/src/adapters/__tests__/strip-foundry-tags.test.ts +++ b/apps/web/src/adapters/__tests__/strip-foundry-tags.test.ts @@ -99,9 +99,15 @@ describe("stripFoundryTags", () => { expect(stripFoundryTags("before
after")).toBe("before\nafter"); }); - it("strips strong and em tags", () => { + it("preserves strong and em tags", () => { expect(stripFoundryTags("bold italic")).toBe( - "bold italic", + "bold italic", + ); + }); + + it("preserves list tags", () => { + expect(stripFoundryTags("")).toBe( + "", ); }); 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..7ac8363 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", @@ -402,16 +470,25 @@ function formatOverlays(overlays: SpellSystem["overlays"]): string | undefined { */ const HEIGHTENED_SUFFIX = /\s*Heightened\s*\([^)]*\)[\s\S]*$/; -function normalizeSpell(item: RawFoundryItem): SpellReference { +function normalizeSpell( + item: RawFoundryItem, + creatureLevel: number, +): SpellReference { const sys = item.system as unknown as SpellSystem; const usesMax = sys.location?.uses?.max; - const rank = sys.location?.heightenedLevel ?? sys.level?.value ?? 0; + const isCantrip = sys.traits?.value?.includes("cantrip") ?? false; + const rank = + sys.location?.heightenedLevel ?? + (isCantrip ? Math.ceil(creatureLevel / 2) : (sys.level?.value ?? 0)); const heightening = formatHeightening(sys.heightening) ?? formatOverlays(sys.overlays); let description: string | undefined; if (sys.description?.value) { let text = stripFoundryTags(sys.description.value); + // Resolve Foundry Roll formula references to the spell's actual rank. + // The parenthesized form (e.g., "(@item.level)d4") is most common. + text = text.replaceAll(/\(?@item\.(?:rank|level)\)?/g, String(rank)); if (heightening) { text = text.replace(HEIGHTENED_SUFFIX, "").trim(); } @@ -439,6 +516,7 @@ function normalizeSpell(item: RawFoundryItem): SpellReference { function normalizeSpellcastingEntry( entry: RawFoundryItem, allSpells: readonly RawFoundryItem[], + creatureLevel: number, ): SpellcastingBlock { const sys = entry.system as unknown as SpellcastingEntrySystem; const tradition = capitalize(sys.tradition?.value ?? ""); @@ -457,7 +535,7 @@ function normalizeSpellcastingEntry( const cantrips: SpellReference[] = []; for (const spell of linkedSpells) { - const ref = normalizeSpell(spell); + const ref = normalizeSpell(spell, creatureLevel); const isCantrip = (spell.system as unknown as SpellSystem).traits?.value?.includes( "cantrip", @@ -490,10 +568,13 @@ function normalizeSpellcastingEntry( function normalizeSpellcasting( items: readonly RawFoundryItem[], + creatureLevel: number, ): SpellcastingBlock[] { const entries = items.filter((i) => i.type === "spellcastingEntry"); const spells = items.filter((i) => i.type === "spell"); - return entries.map((entry) => normalizeSpellcastingEntry(entry, spells)); + return entries.map((entry) => + normalizeSpellcastingEntry(entry, spells, creatureLevel), + ); } // -- Main normalization -- @@ -632,7 +713,17 @@ export function normalizeFoundryCreature( ), ), abilitiesBot: orUndefined(actionsByCategory(items, "offensive")), - spellcasting: orUndefined(normalizeSpellcasting(items)), + spellcasting: orUndefined( + normalizeSpellcasting(items, sys.details?.level?.value ?? 0), + ), + items: + items + .filter(isMundaneItem) + .map((i) => i.name) + .join(", ") || undefined, + equipment: orUndefined( + items.filter(isDetailedEquipment).map(normalizeEquipmentItem), + ), }; } diff --git a/apps/web/src/adapters/strip-foundry-tags.ts b/apps/web/src/adapters/strip-foundry-tags.ts index 7c8e891..5d60523 100644 --- a/apps/web/src/adapters/strip-foundry-tags.ts +++ b/apps/web/src/adapters/strip-foundry-tags.ts @@ -8,7 +8,13 @@ function formatDamage(params: string): string { // "3d6+10[fire]" → "3d6+10 fire" - return params.replaceAll(/\[([^\]]*)\]/g, " $1").trim(); + // "d4[persistent,fire]" → "d4 persistent fire" + return params + .replaceAll( + /\[([^\]]*)\]/g, + (_, type: string) => ` ${type.replaceAll(",", " ")}`, + ) + .trim(); } function formatCheck(params: string): string { @@ -80,11 +86,11 @@ export function stripFoundryTags(html: string): string { // Strip action-glyph spans (content is a number the renderer handles) result = result.replaceAll(/[^<]*<\/span>/gi, ""); - // Strip HTML tags + // Strip HTML tags (preserve for UI rendering) result = result.replaceAll(//gi, "\n"); result = result.replaceAll(//gi, "\n"); result = result.replaceAll(/<\/p>\s*]*>/gi, "\n"); - result = result.replaceAll(/<[^>]+>/g, ""); + result = result.replaceAll(/<(?!\/?(?:strong|em|ul|ol|li)\b)[^>]+>/g, ""); // Decode common HTML entities result = result.replaceAll("&", "&"); @@ -92,6 +98,11 @@ export function stripFoundryTags(html: string): string { result = result.replaceAll(">", ">"); result = result.replaceAll(""", '"'); + // Collapse whitespace around list tags so they don't create extra + // line breaks when rendered with whitespace-pre-line + result = result.replaceAll(/\s*(<\/?(?:ul|ol)>)\s*/g, "$1"); + result = result.replaceAll(/\s*(<\/?li>)\s*/g, "$1"); + // Collapse whitespace result = result.replaceAll(/[ \t]+/g, " "); result = result.replaceAll(/\n\s*\n/g, "\n"); 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 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/rich-description.tsx b/apps/web/src/components/rich-description.tsx new file mode 100644 index 0000000..e0aefae --- /dev/null +++ b/apps/web/src/components/rich-description.tsx @@ -0,0 +1,20 @@ +import { cn } from "../lib/utils.js"; + +/** + * Renders text containing safe HTML formatting tags (strong, em, ul, ol, li) + * preserved by the stripFoundryTags pipeline. All other HTML is already + * stripped before reaching this component. + */ +export function RichDescription({ + text, + className, +}: Readonly<{ text: string; className?: string }>) { + const props = { + className: cn( + "[&_ol]:list-decimal [&_ol]:pl-4 [&_ul]:list-disc [&_ul]:pl-4", + className, + ), + dangerouslySetInnerHTML: { __html: text }, + }; + return
; +} diff --git a/apps/web/src/components/spell-detail-popover.tsx b/apps/web/src/components/spell-detail-popover.tsx index d0bc487..aee950d 100644 --- a/apps/web/src/components/spell-detail-popover.tsx +++ b/apps/web/src/components/spell-detail-popover.tsx @@ -1,9 +1,6 @@ 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 { RichDescription } from "./rich-description.js"; import { ActivityIcon } from "./stat-block-parts.js"; interface SpellDetailPopoverProps { @@ -138,24 +135,6 @@ function SpellMeta({ spell }: Readonly<{ spell: SpellReference }>) { ); } -const SAVE_OUTCOME_REGEX = - /(Critical Success|Critical Failure|Success|Failure)/g; - -function SpellDescription({ text }: Readonly<{ text: string }>) { - const parts = text.split(SAVE_OUTCOME_REGEX); - const elements: React.ReactNode[] = []; - let offset = 0; - for (const part of parts) { - if (SAVE_OUTCOME_REGEX.test(part)) { - elements.push({part}); - } else if (part) { - elements.push({part}); - } - offset += part.length; - } - return

{elements}

; -} - function SpellDetailContent({ spell }: Readonly<{ spell: SpellReference }>) { return (
@@ -163,134 +142,37 @@ function SpellDetailContent({ spell }: Readonly<{ spell: SpellReference }>) { {spell.description ? ( - + ) : (

No description available.

)} {spell.heightening ? ( -

- {spell.heightening} -

+ ) : null}
); } -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 ( -
-