Add PF2e spell description popovers in stat blocks
All checks were successful
CI / check (push) Successful in 2m31s
CI / build-image (push) Successful in 26s

Clicking a spell name in a PF2e creature's stat block now opens a
popover (desktop) or bottom sheet (mobile) showing full spell details:
description, traits, rank, range, target, area, duration, defense,
action cost icons, and heightening rules. All data is sourced from
the embedded Foundry VTT spell items already in the bestiary cache.

- Add SpellReference type replacing bare string spell arrays
- Extract full spell data in pf2e-bestiary-adapter (description,
  traits, traditions, range, target, area, duration, defense,
  action cost, heightening, overlays)
- Strip inline heightening text from descriptions to avoid duplication
- Bold save outcome labels (Critical Success/Failure) in descriptions
- Bump DB_VERSION to 6 for cache invalidation
- Add useSwipeToDismissDown hook for mobile bottom sheet
- Portal popover to document.body to escape transformed ancestors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-04-10 16:18:08 +02:00
parent 9b0cb38897
commit e161645228
17 changed files with 1302 additions and 62 deletions

View File

@@ -4,11 +4,14 @@ import "@testing-library/jest-dom/vitest";
import type { Pf2eCreature } from "@initiative/domain";
import { creatureId } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Pf2eStatBlock } from "../pf2e-stat-block.js";
afterEach(cleanup);
const USES_PER_DAY_REGEX = /×3/;
const HEAL_DESCRIPTION_REGEX = /channel positive energy/;
const PERCEPTION_SENSES_REGEX = /\+2.*Darkvision/;
const SKILLS_REGEX = /Acrobatics \+5.*Stealth \+5/;
const SAVE_CONDITIONAL_REGEX = /\+12.*\+1 status to all saves vs\. magic/;
@@ -90,9 +93,13 @@ const NAUNET: Pf2eCreature = {
name: "Divine Innate Spells",
headerText: "DC 25, attack +17",
daily: [
{ uses: 4, each: true, spells: ["Unfettered Movement (Constant)"] },
{
uses: 4,
each: true,
spells: [{ name: "Unfettered Movement (Constant)" }],
},
],
atWill: ["Detect Magic"],
atWill: [{ name: "Detect Magic" }],
},
],
};
@@ -277,4 +284,87 @@ describe("Pf2eStatBlock", () => {
expect(screen.queryByText(CANTRIPS_REGEX)).not.toBeInTheDocument();
});
});
describe("clickable spells", () => {
const SPELLCASTER: Pf2eCreature = {
...NAUNET,
id: creatureId("test:spellcaster"),
name: "Spellcaster",
spellcasting: [
{
name: "Divine Innate Spells",
headerText: "DC 30, attack +20",
atWill: [{ name: "Detect Magic", rank: 1 }],
daily: [
{
uses: 4,
each: true,
spells: [
{
name: "Heal",
description: "You channel positive energy to heal.",
rank: 4,
usesPerDay: 3,
},
{ name: "Restoration", rank: 4 },
],
},
],
},
],
};
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(),
})),
});
});
it("renders a spell with a description as a clickable button", () => {
renderStatBlock(SPELLCASTER);
expect(screen.getByRole("button", { name: "Heal" })).toBeInTheDocument();
});
it("renders a spell without description as plain text (not a button)", () => {
renderStatBlock(SPELLCASTER);
expect(
screen.queryByRole("button", { name: "Restoration" }),
).not.toBeInTheDocument();
expect(screen.getByText("Restoration")).toBeInTheDocument();
});
it("renders usesPerDay as plain text alongside the spell button", () => {
renderStatBlock(SPELLCASTER);
expect(screen.getByText(USES_PER_DAY_REGEX)).toBeInTheDocument();
});
it("opens the spell popover when a spell button is clicked", async () => {
const user = userEvent.setup();
renderStatBlock(SPELLCASTER);
await user.click(screen.getByRole("button", { name: "Heal" }));
expect(screen.getByText(HEAL_DESCRIPTION_REGEX)).toBeInTheDocument();
});
it("closes the popover when Escape is pressed", async () => {
const user = userEvent.setup();
renderStatBlock(SPELLCASTER);
await user.click(screen.getByRole("button", { name: "Heal" }));
expect(screen.getByText(HEAL_DESCRIPTION_REGEX)).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(
screen.queryByText(HEAL_DESCRIPTION_REGEX),
).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,158 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import type { SpellReference } from "@initiative/domain";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SpellDetailPopover } from "../spell-detail-popover.js";
afterEach(cleanup);
const FIREBALL: SpellReference = {
name: "Fireball",
slug: "fireball",
rank: 3,
description: "A spark leaps from your fingertip to the target.",
traits: ["fire", "manipulate"],
traditions: ["arcane", "primal"],
range: "500 feet",
area: "20-foot burst",
defense: "basic Reflex",
actionCost: "2",
heightening: "Heightened (+1) The damage increases by 2d6.",
};
const ANCHOR: DOMRect = new DOMRect(100, 100, 50, 20);
const SPARK_LEAPS_REGEX = /spark leaps/;
const HEIGHTENED_REGEX = /Heightened.*2d6/;
const RANGE_REGEX = /500 feet/;
const AREA_REGEX = /20-foot burst/;
const DEFENSE_REGEX = /basic Reflex/;
const NO_DESCRIPTION_REGEX = /No description available/;
const DIALOG_LABEL_REGEX = /Spell details: Fireball/;
beforeEach(() => {
// Force desktop variant in jsdom
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("SpellDetailPopover", () => {
it("renders spell name, rank, traits, and description", () => {
render(
<SpellDetailPopover
spell={FIREBALL}
anchorRect={ANCHOR}
onClose={() => {}}
/>,
);
expect(screen.getByText("Fireball")).toBeInTheDocument();
expect(screen.getByText("3rd")).toBeInTheDocument();
expect(screen.getByText("fire")).toBeInTheDocument();
expect(screen.getByText("manipulate")).toBeInTheDocument();
expect(screen.getByText(SPARK_LEAPS_REGEX)).toBeInTheDocument();
});
it("renders heightening rules when present", () => {
render(
<SpellDetailPopover
spell={FIREBALL}
anchorRect={ANCHOR}
onClose={() => {}}
/>,
);
expect(screen.getByText(HEIGHTENED_REGEX)).toBeInTheDocument();
});
it("renders range, area, and defense", () => {
render(
<SpellDetailPopover
spell={FIREBALL}
anchorRect={ANCHOR}
onClose={() => {}}
/>,
);
expect(screen.getByText(RANGE_REGEX)).toBeInTheDocument();
expect(screen.getByText(AREA_REGEX)).toBeInTheDocument();
expect(screen.getByText(DEFENSE_REGEX)).toBeInTheDocument();
});
it("calls onClose when Escape is pressed", () => {
const onClose = vi.fn();
render(
<SpellDetailPopover
spell={FIREBALL}
anchorRect={ANCHOR}
onClose={onClose}
/>,
);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
});
it("shows placeholder when description is missing", () => {
const spell: SpellReference = { name: "Mystery", rank: 1 };
render(
<SpellDetailPopover
spell={spell}
anchorRect={ANCHOR}
onClose={() => {}}
/>,
);
expect(screen.getByText(NO_DESCRIPTION_REGEX)).toBeInTheDocument();
});
it("renders the action cost as an icon when it is a numeric action count", () => {
render(
<SpellDetailPopover
spell={FIREBALL}
anchorRect={ANCHOR}
onClose={() => {}}
/>,
);
// Action cost "2" renders as an SVG ActivityIcon (portaled to body)
const dialog = screen.getByRole("dialog");
expect(dialog.querySelector("svg")).toBeInTheDocument();
});
it("renders non-numeric action cost as text", () => {
const spell: SpellReference = {
...FIREBALL,
actionCost: "1 minute",
};
render(
<SpellDetailPopover
spell={spell}
anchorRect={ANCHOR}
onClose={() => {}}
/>,
);
expect(screen.getByText("1 minute")).toBeInTheDocument();
});
it("uses the dialog role with the spell name as label", () => {
render(
<SpellDetailPopover
spell={FIREBALL}
anchorRect={ANCHOR}
onClose={() => {}}
/>,
);
expect(
screen.getByRole("dialog", { name: DIALOG_LABEL_REGEX }),
).toBeInTheDocument();
});
});

View File

@@ -111,9 +111,15 @@ const DRAGON: Creature = {
{
name: "Innate Spellcasting",
headerText: "The dragon's spellcasting ability is Charisma.",
atWill: ["detect magic", "suggestion"],
daily: [{ uses: 3, each: true, spells: ["fireball", "wall of fire"] }],
restLong: [{ uses: 1, each: false, spells: ["wish"] }],
atWill: [{ name: "detect magic" }, { name: "suggestion" }],
daily: [
{
uses: 3,
each: true,
spells: [{ name: "fireball" }, { name: "wall of fire" }],
},
],
restLong: [{ uses: 1, each: false, spells: [{ name: "wish" }] }],
},
],
};