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

@@ -181,17 +181,20 @@ describe("normalizeBestiary", () => {
expect(sc?.name).toBe("Spellcasting");
expect(sc?.headerText).toContain("DC 15");
expect(sc?.headerText).not.toContain("{@");
expect(sc?.atWill).toEqual(["Detect Magic", "Mage Hand"]);
expect(sc?.atWill).toEqual([
{ name: "Detect Magic" },
{ name: "Mage Hand" },
]);
expect(sc?.daily).toHaveLength(2);
expect(sc?.daily).toContainEqual({
uses: 2,
each: true,
spells: ["Fireball"],
spells: [{ name: "Fireball" }],
});
expect(sc?.daily).toContainEqual({
uses: 1,
each: false,
spells: ["Dimension Door"],
spells: [{ name: "Dimension Door" }],
});
});

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");
});
});
});

View File

@@ -4,6 +4,7 @@ import type {
DailySpells,
LegendaryBlock,
SpellcastingBlock,
SpellReference,
TraitBlock,
TraitListItem,
TraitSegment,
@@ -385,7 +386,7 @@ function normalizeSpellcasting(
const block: {
name: string;
headerText: string;
atWill?: string[];
atWill?: SpellReference[];
daily?: DailySpells[];
restLong?: DailySpells[];
} = {
@@ -396,7 +397,7 @@ function normalizeSpellcasting(
const hidden = new Set(sc.hidden ?? []);
if (sc.will && !hidden.has("will")) {
block.atWill = sc.will.map((s) => stripTags(s));
block.atWill = sc.will.map((s) => ({ name: stripTags(s) }));
}
if (sc.daily) {
@@ -418,7 +419,7 @@ function parseDailyMap(map: Record<string, string[]>): DailySpells[] {
return {
uses,
each,
spells: spells.map((s) => stripTags(s)),
spells: spells.map((s) => ({ name: stripTags(s) })),
};
});
}

View File

@@ -3,7 +3,8 @@ import { type IDBPDatabase, openDB } from "idb";
const DB_NAME = "initiative-bestiary";
const STORE_NAME = "sources";
const DB_VERSION = 5;
// v6 (2026-04-09): SpellReference per-spell data added; old caches are cleared
const DB_VERSION = 6;
interface CachedSourceInfo {
readonly sourceCode: string;

View File

@@ -2,6 +2,7 @@ import type {
CreatureId,
Pf2eCreature,
SpellcastingBlock,
SpellReference,
TraitBlock,
} from "@initiative/domain";
import { creatureId } from "@initiative/domain";
@@ -78,13 +79,39 @@ interface SpellcastingEntrySystem {
}
interface SpellSystem {
slug?: string;
location?: {
value: string;
heightenedLevel?: number;
uses?: { max: number; value: number };
};
level?: { value: number };
traits?: { value: string[] };
traits?: { rarity?: string; value: string[]; traditions?: string[] };
description?: { value: string };
range?: { value: string };
target?: { value: string };
area?: { type?: string; value?: number; details?: string };
duration?: { value: string; sustained?: boolean };
time?: { value: string };
defense?: {
save?: { statistic: string; basic?: boolean };
passive?: { statistic: string };
};
heightening?:
| {
type: "fixed";
levels: Record<string, { text?: string }>;
}
| {
type: "interval";
interval: number;
damage?: { value: string };
}
| undefined;
overlays?: Record<
string,
{ name?: string; system?: { description?: { value: string } } }
>;
}
const SIZE_MAP: Record<string, string> = {
@@ -311,18 +338,102 @@ function normalizeAbility(item: RawFoundryItem): TraitBlock {
// -- Spellcasting normalization --
function classifySpell(spell: RawFoundryItem): {
isCantrip: boolean;
rank: number;
label: string;
} {
const sys = spell.system as unknown as SpellSystem;
const isCantrip = (sys.traits?.value ?? []).includes("cantrip");
function formatRange(range: { value: string } | undefined): string | undefined {
if (!range?.value) return undefined;
return range.value;
}
function formatArea(
area: { type?: string; value?: number; details?: string } | undefined,
): string | undefined {
if (!area) return undefined;
if (area.value && area.type) return `${area.value}-foot ${area.type}`;
return area.details ?? undefined;
}
function formatDefense(defense: SpellSystem["defense"]): string | undefined {
if (!defense) return undefined;
if (defense.save) {
const stat = capitalize(defense.save.statistic);
return defense.save.basic ? `basic ${stat}` : stat;
}
if (defense.passive) return capitalize(defense.passive.statistic);
return undefined;
}
function formatHeightening(
heightening: SpellSystem["heightening"],
): string | undefined {
if (!heightening) return undefined;
if (heightening.type === "fixed") {
const parts = Object.entries(heightening.levels)
.filter(([, lvl]) => lvl.text)
.map(
([rank, lvl]) =>
`Heightened (${rank}) ${stripFoundryTags(lvl.text as string)}`,
);
return parts.length > 0 ? parts.join("\n") : undefined;
}
if (heightening.type === "interval") {
const dmg = heightening.damage?.value
? ` damage increases by ${heightening.damage.value}`
: "";
return `Heightened (+${heightening.interval})${dmg}`;
}
return undefined;
}
function formatOverlays(overlays: SpellSystem["overlays"]): string | undefined {
if (!overlays) return undefined;
const parts: string[] = [];
for (const overlay of Object.values(overlays)) {
const desc = overlay.system?.description?.value;
if (!desc) continue;
const label = overlay.name ? `${overlay.name}: ` : "";
parts.push(`${label}${stripFoundryTags(desc)}`);
}
return parts.length > 0 ? parts.join("\n") : undefined;
}
/**
* Foundry descriptions often include heightening rules inline at the end.
* When we extract heightening into a structured field, strip that trailing
* text to avoid duplication.
*/
const HEIGHTENED_SUFFIX = /\s*Heightened\s*\([^)]*\)[\s\S]*$/;
function normalizeSpell(item: RawFoundryItem): 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 uses = sys.location?.uses;
const label =
uses && uses.max > 1 ? `${spell.name} (\u00d7${uses.max})` : spell.name;
return { isCantrip, rank, label };
const heightening =
formatHeightening(sys.heightening) ?? formatOverlays(sys.overlays);
let description: string | undefined;
if (sys.description?.value) {
let text = stripFoundryTags(sys.description.value);
if (heightening) {
text = text.replace(HEIGHTENED_SUFFIX, "").trim();
}
description = text || undefined;
}
return {
name: item.name,
slug: sys.slug,
rank,
description,
traits: sys.traits?.value,
traditions: sys.traits?.traditions,
range: formatRange(sys.range),
target: sys.target?.value || undefined,
area: formatArea(sys.area),
duration: sys.duration?.value || undefined,
defense: formatDefense(sys.defense),
actionCost: sys.time?.value || undefined,
heightening,
usesPerDay: usesMax && usesMax > 1 ? usesMax : undefined,
};
}
function normalizeSpellcastingEntry(
@@ -342,26 +453,31 @@ function normalizeSpellcastingEntry(
(s) => (s.system as unknown as SpellSystem).location?.value === entry._id,
);
const byRank = new Map<number, string[]>();
const cantrips: string[] = [];
const byRank = new Map<number, SpellReference[]>();
const cantrips: SpellReference[] = [];
for (const spell of linkedSpells) {
const { isCantrip, rank, label } = classifySpell(spell);
const ref = normalizeSpell(spell);
const isCantrip =
(spell.system as unknown as SpellSystem).traits?.value?.includes(
"cantrip",
) ?? false;
if (isCantrip) {
cantrips.push(spell.name);
cantrips.push(ref);
continue;
}
const rank = ref.rank ?? 0;
const existing = byRank.get(rank) ?? [];
existing.push(label);
existing.push(ref);
byRank.set(rank, existing);
}
const daily = [...byRank.entries()]
.sort(([a], [b]) => b - a)
.map(([rank, spellNames]) => ({
.map(([rank, spells]) => ({
uses: rank,
each: true,
spells: spellNames,
spells,
}));
return {