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 17s

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>
This commit is contained in:
Lukas
2026-04-10 20:21:11 +02:00
parent e2e8297c95
commit 1eaeecad32
16 changed files with 943 additions and 158 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", () => {
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", () => {

View File

@@ -99,9 +99,15 @@ describe("stripFoundryTags", () => {
expect(stripFoundryTags("before<hr />after")).toBe("before\nafter");
});
it("strips strong and em tags", () => {
it("preserves strong and em tags", () => {
expect(stripFoundryTags("<strong>bold</strong> <em>italic</em>")).toBe(
"bold italic",
"<strong>bold</strong> <em>italic</em>",
);
});
it("preserves list tags", () => {
expect(stripFoundryTags("<ul><li>first</li><li>second</li></ul>")).toBe(
"<ul><li>first</li><li>second</li></ul>",
);
});

View File

@@ -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;

View File

@@ -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<string, string> = {
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),
),
};
}

View File

@@ -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 class="action-glyph">[^<]*<\/span>/gi, "");
// Strip HTML tags
// Strip HTML tags (preserve <strong> for UI rendering)
result = result.replaceAll(/<br\s*\/?>/gi, "\n");
result = result.replaceAll(/<hr\s*\/?>/gi, "\n");
result = result.replaceAll(/<\/p>\s*<p[^>]*>/gi, "\n");
result = result.replaceAll(/<[^>]+>/g, "");
result = result.replaceAll(/<(?!\/?(?:strong|em|ul|ol|li)\b)[^>]+>/g, "");
// Decode common HTML entities
result = result.replaceAll("&amp;", "&");
@@ -92,6 +98,11 @@ export function stripFoundryTags(html: string): string {
result = result.replaceAll("&gt;", ">");
result = result.replaceAll("&quot;", '"');
// 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");

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_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,

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,72 @@
import type { EquipmentItem } from "@initiative/domain";
import { DetailPopover } from "./detail-popover.js";
import { RichDescription } from "./rich-description.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 ? (
<RichDescription
text={item.description}
className="whitespace-pre-line text-foreground"
/>
) : (
<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 { 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<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>) {
const [openSpell, setOpenSpell] = useState<{
spell: SpellReference;
@@ -112,6 +146,15 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
[],
);
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<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 ? (
<SpellDetailPopover
spell={openSpell.spell}
@@ -263,6 +319,13 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
onClose={handleCloseSpell}
/>
) : null}
{openEquipment ? (
<EquipmentDetailPopover
item={openEquipment.item}
anchorRect={openEquipment.rect}
onClose={handleCloseEquipment}
/>
) : null}
</div>
);
}

View File

@@ -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 <div {...props} />;
}

View File

@@ -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(<strong key={`b-${offset}`}>{part}</strong>);
} else if (part) {
elements.push(<span key={`t-${offset}`}>{part}</span>);
}
offset += part.length;
}
return <p className="whitespace-pre-line text-foreground">{elements}</p>;
}
function SpellDetailContent({ spell }: Readonly<{ spell: SpellReference }>) {
return (
<div className="space-y-2 text-sm">
@@ -163,134 +142,37 @@ function SpellDetailContent({ spell }: Readonly<{ spell: SpellReference }>) {
<SpellTraits traits={spell.traits ?? []} />
<SpellMeta spell={spell} />
{spell.description ? (
<SpellDescription text={spell.description} />
<RichDescription
text={spell.description}
className="whitespace-pre-line text-foreground"
/>
) : (
<p className="text-muted-foreground italic">
No description available.
</p>
)}
{spell.heightening ? (
<p className="whitespace-pre-line text-foreground text-xs">
{spell.heightening}
</p>
<RichDescription
text={spell.heightening}
className="whitespace-pre-line text-foreground text-xs"
/>
) : null}
</div>
);
}
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({
spell,
anchorRect,
onClose,
}: Readonly<SpellDetailPopoverProps>) {
const [isDesktop, setIsDesktop] = useState(
() => globalThis.matchMedia("(min-width: 1024px)").matches,
return (
<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

@@ -3,6 +3,7 @@ import type {
TraitBlock,
TraitSegment,
} from "@initiative/domain";
import { RichDescription } from "./rich-description.js";
export function PropertyLine({
label,
@@ -39,20 +40,22 @@ function TraitSegments({
{segments.map((seg, i) => {
if (seg.type === "text") {
return (
<span key={segmentKey(seg)}>
{i === 0 ? ` ${seg.value}` : seg.value}
</span>
<RichDescription
key={segmentKey(seg)}
text={i === 0 ? ` ${seg.value}` : seg.value}
className="inline whitespace-pre-line"
/>
);
}
return (
<div key={segmentKey(seg)} className="mt-1 space-y-0.5">
{seg.items.map((item) => (
<p key={item.label ?? item.text}>
<div key={item.label ?? item.text}>
{item.label != null && (
<span className="font-semibold">{item.label}. </span>
)}
{item.text}
</p>
<RichDescription text={item.text} className="inline" />
</div>
))}
</div>
);