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

@@ -593,11 +593,11 @@ describe("normalizeFoundryCreature", () => {
const sc = creature.spellcasting?.[0];
expect(sc?.name).toBe("Primal Prepared Spells");
expect(sc?.headerText).toBe("DC 30, attack +22");
expect(sc?.daily).toEqual([
{ uses: 6, each: true, spells: ["Earthquake"] },
{ uses: 3, each: true, spells: ["Heal"] },
]);
expect(sc?.atWill).toEqual(["Detect Magic"]);
expect(sc?.daily?.map((d) => d.uses)).toEqual([6, 3]);
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);
});
it("normalizes innate spells with uses", () => {
@@ -633,13 +633,334 @@ describe("normalizeFoundryCreature", () => {
);
const sc = creature.spellcasting?.[0];
expect(sc?.headerText).toBe("DC 32");
expect(sc?.daily).toEqual([
{
uses: 1,
each: true,
spells: ["Sure Strike (\u00d73)"],
},
]);
expect(sc?.daily).toHaveLength(1);
const spell = sc?.daily?.[0]?.spells[0];
expect(spell?.name).toBe("Sure Strike");
expect(spell?.usesPerDay).toBe(3);
expect(spell?.rank).toBe(1);
});
it("preserves full spell data including description and heightening", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "entry1",
name: "Divine Innate Spells",
type: "spellcastingEntry",
system: {
tradition: { value: "divine" },
prepared: { value: "innate" },
spelldc: { dc: 35, value: 27 },
},
},
{
_id: "s1",
name: "Heal",
type: "spell",
system: {
slug: "heal",
location: { value: "entry1" },
level: { value: 6 },
traits: {
rarity: "common",
value: ["healing", "vitality"],
traditions: ["divine", "primal"],
},
description: {
value:
"<p>You channel @UUID[Compendium.pf2e.spells.Item.Heal]{positive} energy to heal the living. The target regains @Damage[2d8[vitality]] Hit Points.</p>",
},
range: { value: "30 feet" },
target: { value: "1 willing creature" },
duration: { value: "" },
defense: undefined,
time: { value: "1" },
heightening: {
type: "interval",
interval: 1,
damage: { value: "2d8" },
},
},
},
{
_id: "s2",
name: "Force Barrage",
type: "spell",
system: {
location: { value: "entry1" },
level: { value: 1 },
traits: { value: ["concentrate", "manipulate"] },
},
},
],
}),
);
const sc = creature.spellcasting?.[0];
expect(sc).toBeDefined();
const heal = sc?.daily
?.flatMap((d) => d.spells)
.find((s) => s.name === "Heal");
expect(heal).toBeDefined();
expect(heal?.slug).toBe("heal");
expect(heal?.rank).toBe(6);
expect(heal?.range).toBe("30 feet");
expect(heal?.target).toBe("1 willing creature");
expect(heal?.traits).toEqual(["healing", "vitality"]);
expect(heal?.traditions).toEqual(["divine", "primal"]);
expect(heal?.actionCost).toBe("1");
// Foundry tags stripped from description
expect(heal?.description).toContain("positive");
expect(heal?.description).not.toContain("@UUID");
expect(heal?.description).not.toContain("@Damage");
// Interval heightening formatted and not duplicated in description
expect(heal?.heightening).toBe("Heightened (+1) damage increases by 2d8");
// Spell without optional data still has name + rank
const fb = sc?.daily
?.flatMap((d) => d.spells)
.find((s) => s.name === "Force Barrage");
expect(fb).toBeDefined();
expect(fb?.rank).toBe(1);
expect(fb?.description).toBeUndefined();
expect(fb?.usesPerDay).toBeUndefined();
});
it("formats fixed-type heightening levels", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "entry1",
name: "Divine Prepared Spells",
type: "spellcastingEntry",
system: {
tradition: { value: "divine" },
prepared: { value: "prepared" },
spelldc: { dc: 30 },
},
},
{
_id: "s1",
name: "Magic Missile",
type: "spell",
system: {
location: { value: "entry1" },
level: { value: 1 },
traits: { value: [] },
heightening: {
type: "fixed",
levels: {
"3": { text: "<p>You shoot two more missiles.</p>" },
"5": { text: "<p>You shoot four more missiles.</p>" },
},
},
},
},
],
}),
);
const spell = creature.spellcasting?.[0]?.daily
?.flatMap((d) => d.spells)
.find((s) => s.name === "Magic Missile");
expect(spell?.heightening).toContain(
"Heightened (3) You shoot two more missiles.",
);
expect(spell?.heightening).toContain(
"Heightened (5) You shoot four more missiles.",
);
});
it("formats save defense", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "entry1",
name: "Arcane Innate Spells",
type: "spellcastingEntry",
system: {
tradition: { value: "arcane" },
prepared: { value: "innate" },
spelldc: { dc: 25 },
},
},
{
_id: "s1",
name: "Fireball",
type: "spell",
system: {
location: { value: "entry1" },
level: { value: 3 },
traits: { value: ["fire"] },
area: { type: "burst", value: 20 },
defense: {
save: { statistic: "reflex", basic: true },
},
},
},
],
}),
);
const fireball = creature.spellcasting?.[0]?.daily
?.flatMap((d) => d.spells)
.find((s) => s.name === "Fireball");
expect(fireball?.defense).toBe("basic Reflex");
expect(fireball?.area).toBe("20-foot burst");
});
it("strips inline heightening text from description when structured heightening exists", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "entry1",
name: "Arcane Prepared Spells",
type: "spellcastingEntry",
system: {
tradition: { value: "arcane" },
prepared: { value: "prepared" },
spelldc: { dc: 30 },
},
},
{
_id: "s1",
name: "Chain Lightning",
type: "spell",
system: {
location: { value: "entry1" },
level: { value: 6 },
traits: { value: ["electricity"] },
description: {
value:
"<p>You discharge a bolt of lightning. The damage is 8d12.</p><p>Heightened (+1) The damage increases by 1d12.</p>",
},
heightening: {
type: "interval",
interval: 1,
damage: { value: "1d12" },
},
},
},
],
}),
);
const spell = creature.spellcasting?.[0]?.daily
?.flatMap((d) => d.spells)
.find((s) => s.name === "Chain Lightning");
expect(spell?.description).toBe(
"You discharge a bolt of lightning. The damage is 8d12.",
);
expect(spell?.description).not.toContain("Heightened");
expect(spell?.heightening).toBe(
"Heightened (+1) damage increases by 1d12",
);
});
it("formats overlays when heightening is absent", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "entry1",
name: "Arcane Innate Spells",
type: "spellcastingEntry",
system: {
tradition: { value: "arcane" },
prepared: { value: "innate" },
spelldc: { dc: 28 },
},
},
{
_id: "s1",
name: "Force Barrage",
type: "spell",
system: {
location: { value: "entry1" },
level: { value: 1 },
traits: { value: ["force", "manipulate"] },
description: {
value: "<p>You fire darts of force.</p>",
},
overlays: {
variant1: {
name: "2 actions",
system: {
description: {
value: "<p>You fire two darts.</p>",
},
},
},
variant2: {
name: "3 actions",
system: {
description: {
value: "<p>You fire three darts.</p>",
},
},
},
},
},
},
],
}),
);
const spell = creature.spellcasting?.[0]?.daily
?.flatMap((d) => d.spells)
.find((s) => s.name === "Force Barrage");
expect(spell?.heightening).toContain("2 actions: You fire two darts.");
expect(spell?.heightening).toContain("3 actions: You fire three darts.");
});
it("prefers heightening over overlays when both present", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "entry1",
name: "Arcane Prepared Spells",
type: "spellcastingEntry",
system: {
tradition: { value: "arcane" },
prepared: { value: "prepared" },
spelldc: { dc: 30 },
},
},
{
_id: "s1",
name: "Test Spell",
type: "spell",
system: {
location: { value: "entry1" },
level: { value: 1 },
traits: { value: [] },
heightening: {
type: "interval",
interval: 2,
damage: { value: "1d6" },
},
overlays: {
variant1: {
name: "Variant",
system: {
description: {
value: "<p>Should be ignored.</p>",
},
},
},
},
},
},
],
}),
);
const spell = creature.spellcasting?.[0]?.daily
?.flatMap((d) => d.spells)
.find((s) => s.name === "Test Spell");
expect(spell?.heightening).toBe(
"Heightened (+2) damage increases by 1d6",
);
expect(spell?.heightening).not.toContain("Should be ignored");
});
});
});