Add PF2e spell description popovers in stat blocks
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:
@@ -181,17 +181,20 @@ describe("normalizeBestiary", () => {
|
|||||||
expect(sc?.name).toBe("Spellcasting");
|
expect(sc?.name).toBe("Spellcasting");
|
||||||
expect(sc?.headerText).toContain("DC 15");
|
expect(sc?.headerText).toContain("DC 15");
|
||||||
expect(sc?.headerText).not.toContain("{@");
|
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).toHaveLength(2);
|
||||||
expect(sc?.daily).toContainEqual({
|
expect(sc?.daily).toContainEqual({
|
||||||
uses: 2,
|
uses: 2,
|
||||||
each: true,
|
each: true,
|
||||||
spells: ["Fireball"],
|
spells: [{ name: "Fireball" }],
|
||||||
});
|
});
|
||||||
expect(sc?.daily).toContainEqual({
|
expect(sc?.daily).toContainEqual({
|
||||||
uses: 1,
|
uses: 1,
|
||||||
each: false,
|
each: false,
|
||||||
spells: ["Dimension Door"],
|
spells: [{ name: "Dimension Door" }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -593,11 +593,11 @@ describe("normalizeFoundryCreature", () => {
|
|||||||
const sc = creature.spellcasting?.[0];
|
const sc = creature.spellcasting?.[0];
|
||||||
expect(sc?.name).toBe("Primal Prepared Spells");
|
expect(sc?.name).toBe("Primal Prepared Spells");
|
||||||
expect(sc?.headerText).toBe("DC 30, attack +22");
|
expect(sc?.headerText).toBe("DC 30, attack +22");
|
||||||
expect(sc?.daily).toEqual([
|
expect(sc?.daily?.map((d) => d.uses)).toEqual([6, 3]);
|
||||||
{ uses: 6, each: true, spells: ["Earthquake"] },
|
expect(sc?.daily?.[0]?.spells.map((s) => s.name)).toEqual(["Earthquake"]);
|
||||||
{ uses: 3, each: true, spells: ["Heal"] },
|
expect(sc?.daily?.[1]?.spells.map((s) => s.name)).toEqual(["Heal"]);
|
||||||
]);
|
expect(sc?.atWill?.map((s) => s.name)).toEqual(["Detect Magic"]);
|
||||||
expect(sc?.atWill).toEqual(["Detect Magic"]);
|
expect(sc?.atWill?.[0]?.rank).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes innate spells with uses", () => {
|
it("normalizes innate spells with uses", () => {
|
||||||
@@ -633,13 +633,334 @@ describe("normalizeFoundryCreature", () => {
|
|||||||
);
|
);
|
||||||
const sc = creature.spellcasting?.[0];
|
const sc = creature.spellcasting?.[0];
|
||||||
expect(sc?.headerText).toBe("DC 32");
|
expect(sc?.headerText).toBe("DC 32");
|
||||||
expect(sc?.daily).toEqual([
|
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: [
|
||||||
{
|
{
|
||||||
uses: 1,
|
_id: "entry1",
|
||||||
each: true,
|
name: "Divine Innate Spells",
|
||||||
spells: ["Sure Strike (\u00d73)"],
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
DailySpells,
|
DailySpells,
|
||||||
LegendaryBlock,
|
LegendaryBlock,
|
||||||
SpellcastingBlock,
|
SpellcastingBlock,
|
||||||
|
SpellReference,
|
||||||
TraitBlock,
|
TraitBlock,
|
||||||
TraitListItem,
|
TraitListItem,
|
||||||
TraitSegment,
|
TraitSegment,
|
||||||
@@ -385,7 +386,7 @@ function normalizeSpellcasting(
|
|||||||
const block: {
|
const block: {
|
||||||
name: string;
|
name: string;
|
||||||
headerText: string;
|
headerText: string;
|
||||||
atWill?: string[];
|
atWill?: SpellReference[];
|
||||||
daily?: DailySpells[];
|
daily?: DailySpells[];
|
||||||
restLong?: DailySpells[];
|
restLong?: DailySpells[];
|
||||||
} = {
|
} = {
|
||||||
@@ -396,7 +397,7 @@ function normalizeSpellcasting(
|
|||||||
const hidden = new Set(sc.hidden ?? []);
|
const hidden = new Set(sc.hidden ?? []);
|
||||||
|
|
||||||
if (sc.will && !hidden.has("will")) {
|
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) {
|
if (sc.daily) {
|
||||||
@@ -418,7 +419,7 @@ function parseDailyMap(map: Record<string, string[]>): DailySpells[] {
|
|||||||
return {
|
return {
|
||||||
uses,
|
uses,
|
||||||
each,
|
each,
|
||||||
spells: spells.map((s) => stripTags(s)),
|
spells: spells.map((s) => ({ name: stripTags(s) })),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { type IDBPDatabase, openDB } from "idb";
|
|||||||
|
|
||||||
const DB_NAME = "initiative-bestiary";
|
const DB_NAME = "initiative-bestiary";
|
||||||
const STORE_NAME = "sources";
|
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 {
|
interface CachedSourceInfo {
|
||||||
readonly sourceCode: string;
|
readonly sourceCode: string;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
CreatureId,
|
CreatureId,
|
||||||
Pf2eCreature,
|
Pf2eCreature,
|
||||||
SpellcastingBlock,
|
SpellcastingBlock,
|
||||||
|
SpellReference,
|
||||||
TraitBlock,
|
TraitBlock,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { creatureId } from "@initiative/domain";
|
import { creatureId } from "@initiative/domain";
|
||||||
@@ -78,13 +79,39 @@ interface SpellcastingEntrySystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SpellSystem {
|
interface SpellSystem {
|
||||||
|
slug?: string;
|
||||||
location?: {
|
location?: {
|
||||||
value: string;
|
value: string;
|
||||||
heightenedLevel?: number;
|
heightenedLevel?: number;
|
||||||
uses?: { max: number; value: number };
|
uses?: { max: number; value: number };
|
||||||
};
|
};
|
||||||
level?: { 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> = {
|
const SIZE_MAP: Record<string, string> = {
|
||||||
@@ -311,18 +338,102 @@ function normalizeAbility(item: RawFoundryItem): TraitBlock {
|
|||||||
|
|
||||||
// -- Spellcasting normalization --
|
// -- Spellcasting normalization --
|
||||||
|
|
||||||
function classifySpell(spell: RawFoundryItem): {
|
function formatRange(range: { value: string } | undefined): string | undefined {
|
||||||
isCantrip: boolean;
|
if (!range?.value) return undefined;
|
||||||
rank: number;
|
return range.value;
|
||||||
label: string;
|
}
|
||||||
} {
|
|
||||||
const sys = spell.system as unknown as SpellSystem;
|
function formatArea(
|
||||||
const isCantrip = (sys.traits?.value ?? []).includes("cantrip");
|
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 rank = sys.location?.heightenedLevel ?? sys.level?.value ?? 0;
|
||||||
const uses = sys.location?.uses;
|
const heightening =
|
||||||
const label =
|
formatHeightening(sys.heightening) ?? formatOverlays(sys.overlays);
|
||||||
uses && uses.max > 1 ? `${spell.name} (\u00d7${uses.max})` : spell.name;
|
|
||||||
return { isCantrip, rank, label };
|
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(
|
function normalizeSpellcastingEntry(
|
||||||
@@ -342,26 +453,31 @@ function normalizeSpellcastingEntry(
|
|||||||
(s) => (s.system as unknown as SpellSystem).location?.value === entry._id,
|
(s) => (s.system as unknown as SpellSystem).location?.value === entry._id,
|
||||||
);
|
);
|
||||||
|
|
||||||
const byRank = new Map<number, string[]>();
|
const byRank = new Map<number, SpellReference[]>();
|
||||||
const cantrips: string[] = [];
|
const cantrips: SpellReference[] = [];
|
||||||
|
|
||||||
for (const spell of linkedSpells) {
|
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) {
|
if (isCantrip) {
|
||||||
cantrips.push(spell.name);
|
cantrips.push(ref);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const rank = ref.rank ?? 0;
|
||||||
const existing = byRank.get(rank) ?? [];
|
const existing = byRank.get(rank) ?? [];
|
||||||
existing.push(label);
|
existing.push(ref);
|
||||||
byRank.set(rank, existing);
|
byRank.set(rank, existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
const daily = [...byRank.entries()]
|
const daily = [...byRank.entries()]
|
||||||
.sort(([a], [b]) => b - a)
|
.sort(([a], [b]) => b - a)
|
||||||
.map(([rank, spellNames]) => ({
|
.map(([rank, spells]) => ({
|
||||||
uses: rank,
|
uses: rank,
|
||||||
each: true,
|
each: true,
|
||||||
spells: spellNames,
|
spells,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
import type { Pf2eCreature } from "@initiative/domain";
|
import type { Pf2eCreature } from "@initiative/domain";
|
||||||
import { creatureId } from "@initiative/domain";
|
import { creatureId } from "@initiative/domain";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
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";
|
import { Pf2eStatBlock } from "../pf2e-stat-block.js";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const USES_PER_DAY_REGEX = /×3/;
|
||||||
|
const HEAL_DESCRIPTION_REGEX = /channel positive energy/;
|
||||||
const PERCEPTION_SENSES_REGEX = /\+2.*Darkvision/;
|
const PERCEPTION_SENSES_REGEX = /\+2.*Darkvision/;
|
||||||
const SKILLS_REGEX = /Acrobatics \+5.*Stealth \+5/;
|
const SKILLS_REGEX = /Acrobatics \+5.*Stealth \+5/;
|
||||||
const SAVE_CONDITIONAL_REGEX = /\+12.*\+1 status to all saves vs\. magic/;
|
const SAVE_CONDITIONAL_REGEX = /\+12.*\+1 status to all saves vs\. magic/;
|
||||||
@@ -90,9 +93,13 @@ const NAUNET: Pf2eCreature = {
|
|||||||
name: "Divine Innate Spells",
|
name: "Divine Innate Spells",
|
||||||
headerText: "DC 25, attack +17",
|
headerText: "DC 25, attack +17",
|
||||||
daily: [
|
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();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
158
apps/web/src/components/__tests__/spell-detail-popover.test.tsx
Normal file
158
apps/web/src/components/__tests__/spell-detail-popover.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -111,9 +111,15 @@ const DRAGON: Creature = {
|
|||||||
{
|
{
|
||||||
name: "Innate Spellcasting",
|
name: "Innate Spellcasting",
|
||||||
headerText: "The dragon's spellcasting ability is Charisma.",
|
headerText: "The dragon's spellcasting ability is Charisma.",
|
||||||
atWill: ["detect magic", "suggestion"],
|
atWill: [{ name: "detect magic" }, { name: "suggestion" }],
|
||||||
daily: [{ uses: 3, each: true, spells: ["fireball", "wall of fire"] }],
|
daily: [
|
||||||
restLong: [{ uses: 1, each: false, spells: ["wish"] }],
|
{
|
||||||
|
uses: 3,
|
||||||
|
each: true,
|
||||||
|
spells: [{ name: "fireball" }, { name: "wall of fire" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
restLong: [{ uses: 1, each: false, spells: [{ name: "wish" }] }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
|||||||
{sc.atWill && sc.atWill.length > 0 && (
|
{sc.atWill && sc.atWill.length > 0 && (
|
||||||
<div className="pl-2">
|
<div className="pl-2">
|
||||||
<span className="font-semibold">At Will:</span>{" "}
|
<span className="font-semibold">At Will:</span>{" "}
|
||||||
{sc.atWill.join(", ")}
|
{sc.atWill.map((s) => s.name).join(", ")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sc.daily?.map((d) => (
|
{sc.daily?.map((d) => (
|
||||||
@@ -143,7 +143,7 @@ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
|||||||
{d.uses}/day
|
{d.uses}/day
|
||||||
{d.each ? " each" : ""}:
|
{d.each ? " each" : ""}:
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
{d.spells.join(", ")}
|
{d.spells.map((s) => s.name).join(", ")}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{sc.restLong?.map((d) => (
|
{sc.restLong?.map((d) => (
|
||||||
@@ -155,7 +155,7 @@ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
|||||||
{d.uses}/long rest
|
{d.uses}/long rest
|
||||||
{d.each ? " each" : ""}:
|
{d.each ? " each" : ""}:
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
{d.spells.join(", ")}
|
{d.spells.map((s) => s.name).join(", ")}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { Pf2eCreature } from "@initiative/domain";
|
import type { Pf2eCreature, SpellReference } from "@initiative/domain";
|
||||||
import { formatInitiativeModifier } from "@initiative/domain";
|
import { formatInitiativeModifier } from "@initiative/domain";
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { SpellDetailPopover } from "./spell-detail-popover.js";
|
||||||
import {
|
import {
|
||||||
PropertyLine,
|
PropertyLine,
|
||||||
SectionDivider,
|
SectionDivider,
|
||||||
@@ -34,7 +36,83 @@ function formatMod(mod: number): string {
|
|||||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SpellLinkProps {
|
||||||
|
readonly spell: SpellReference;
|
||||||
|
readonly onOpen: (spell: SpellReference, rect: DOMRect) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsesPerDay({ count }: Readonly<{ count: number | undefined }>) {
|
||||||
|
if (count === undefined || count <= 1) return null;
|
||||||
|
return <span> (×{count})</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellLink({ spell, onOpen }: Readonly<SpellLinkProps>) {
|
||||||
|
const ref = useRef<HTMLButtonElement>(null);
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (!spell.description) return;
|
||||||
|
const rect = ref.current?.getBoundingClientRect();
|
||||||
|
if (rect) onOpen(spell, rect);
|
||||||
|
}, [spell, onOpen]);
|
||||||
|
|
||||||
|
if (!spell.description) {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{spell.name}
|
||||||
|
<UsesPerDay count={spell.usesPerDay} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
className="cursor-pointer text-foreground underline decoration-dotted underline-offset-2 hover:text-hover-neutral"
|
||||||
|
>
|
||||||
|
{spell.name}
|
||||||
|
</button>
|
||||||
|
<UsesPerDay count={spell.usesPerDay} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpellListLineProps {
|
||||||
|
readonly label: string;
|
||||||
|
readonly spells: readonly SpellReference[];
|
||||||
|
readonly onOpen: (spell: SpellReference, rect: DOMRect) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellListLine({
|
||||||
|
label,
|
||||||
|
spells,
|
||||||
|
onOpen,
|
||||||
|
}: Readonly<SpellListLineProps>) {
|
||||||
|
return (
|
||||||
|
<div className="pl-2">
|
||||||
|
<span className="font-semibold">{label}:</span>{" "}
|
||||||
|
{spells.map((spell, i) => (
|
||||||
|
<span key={spell.slug ?? spell.name}>
|
||||||
|
{i > 0 ? ", " : ""}
|
||||||
|
<SpellLink spell={spell} onOpen={onOpen} />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||||
|
const [openSpell, setOpenSpell] = useState<{
|
||||||
|
spell: SpellReference;
|
||||||
|
rect: DOMRect;
|
||||||
|
} | null>(null);
|
||||||
|
const handleOpenSpell = useCallback(
|
||||||
|
(spell: SpellReference, rect: DOMRect) => setOpenSpell({ spell, rect }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const handleCloseSpell = useCallback(() => setOpenSpell(null), []);
|
||||||
|
|
||||||
const abilityEntries = [
|
const abilityEntries = [
|
||||||
{ label: "Str", mod: creature.abilityMods.str },
|
{ label: "Str", mod: creature.abilityMods.str },
|
||||||
{ label: "Dex", mod: creature.abilityMods.dex },
|
{ label: "Dex", mod: creature.abilityMods.dex },
|
||||||
@@ -152,23 +230,31 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
|||||||
{sc.headerText}
|
{sc.headerText}
|
||||||
</div>
|
</div>
|
||||||
{sc.daily?.map((d) => (
|
{sc.daily?.map((d) => (
|
||||||
<div key={d.uses} className="pl-2">
|
<SpellListLine
|
||||||
<span className="font-semibold">
|
key={d.uses}
|
||||||
{d.uses === 0 ? "Cantrips" : `Rank ${d.uses}`}:
|
label={d.uses === 0 ? "Cantrips" : `Rank ${d.uses}`}
|
||||||
</span>{" "}
|
spells={d.spells}
|
||||||
{d.spells.join(", ")}
|
onOpen={handleOpenSpell}
|
||||||
</div>
|
/>
|
||||||
))}
|
))}
|
||||||
{sc.atWill && sc.atWill.length > 0 && (
|
{sc.atWill && sc.atWill.length > 0 && (
|
||||||
<div className="pl-2">
|
<SpellListLine
|
||||||
<span className="font-semibold">Cantrips:</span>{" "}
|
label="Cantrips"
|
||||||
{sc.atWill.join(", ")}
|
spells={sc.atWill}
|
||||||
</div>
|
onOpen={handleOpenSpell}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{openSpell ? (
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={openSpell.spell}
|
||||||
|
anchorRect={openSpell.rect}
|
||||||
|
onClose={handleCloseSpell}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
296
apps/web/src/components/spell-detail-popover.tsx
Normal file
296
apps/web/src/components/spell-detail-popover.tsx
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
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 { ActivityIcon } from "./stat-block-parts.js";
|
||||||
|
|
||||||
|
interface SpellDetailPopoverProps {
|
||||||
|
readonly spell: SpellReference;
|
||||||
|
readonly anchorRect: DOMRect;
|
||||||
|
readonly onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANK_LABELS = [
|
||||||
|
"Cantrip",
|
||||||
|
"1st",
|
||||||
|
"2nd",
|
||||||
|
"3rd",
|
||||||
|
"4th",
|
||||||
|
"5th",
|
||||||
|
"6th",
|
||||||
|
"7th",
|
||||||
|
"8th",
|
||||||
|
"9th",
|
||||||
|
"10th",
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatRank(rank: number | undefined): string {
|
||||||
|
if (rank === undefined) return "";
|
||||||
|
return RANK_LABELS[rank] ?? `Rank ${rank}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseActionCost(cost: string): ActivityCost | null {
|
||||||
|
if (cost === "free") return { number: 1, unit: "free" };
|
||||||
|
if (cost === "reaction") return { number: 1, unit: "reaction" };
|
||||||
|
const n = Number(cost);
|
||||||
|
if (n >= 1 && n <= 3) return { number: n, unit: "action" };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellActionCost({ cost }: Readonly<{ cost: string | undefined }>) {
|
||||||
|
if (!cost) return null;
|
||||||
|
const activity = parseActionCost(cost);
|
||||||
|
if (activity) {
|
||||||
|
return (
|
||||||
|
<span className="shrink-0 text-lg">
|
||||||
|
<ActivityIcon activity={activity} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="shrink-0 text-muted-foreground text-xs">{cost}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellHeader({ spell }: Readonly<{ spell: SpellReference }>) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h3 className="font-bold text-lg text-stat-heading">{spell.name}</h3>
|
||||||
|
<SpellActionCost cost={spell.actionCost} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellTraits({ traits }: Readonly<{ traits: readonly string[] }>) {
|
||||||
|
if (traits.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LabeledValue({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: Readonly<{ label: string; value: string }>) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="font-semibold">{label}</span> {value}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellRangeLine({ spell }: Readonly<{ spell: SpellReference }>) {
|
||||||
|
const items: { label: string; value: string }[] = [];
|
||||||
|
if (spell.range) items.push({ label: "Range", value: spell.range });
|
||||||
|
if (spell.target) items.push({ label: "Target", value: spell.target });
|
||||||
|
if (spell.area) items.push({ label: "Area", value: spell.area });
|
||||||
|
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<span key={item.label}>
|
||||||
|
{i > 0 ? "; " : ""}
|
||||||
|
<LabeledValue label={item.label} value={item.value} />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellMeta({ spell }: Readonly<{ spell: SpellReference }>) {
|
||||||
|
const hasTraditions =
|
||||||
|
spell.traditions !== undefined && spell.traditions.length > 0;
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5 text-xs">
|
||||||
|
{spell.rank === undefined ? null : (
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">{formatRank(spell.rank)}</span>
|
||||||
|
{hasTraditions ? (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{" "}
|
||||||
|
({spell.traditions?.join(", ")})
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<SpellRangeLine spell={spell} />
|
||||||
|
{spell.duration ? (
|
||||||
|
<div>
|
||||||
|
<LabeledValue label="Duration" value={spell.duration} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{spell.defense ? (
|
||||||
|
<div>
|
||||||
|
<LabeledValue label="Defense" value={spell.defense} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<SpellHeader spell={spell} />
|
||||||
|
<SpellTraits traits={spell.traits ?? []} />
|
||||||
|
<SpellMeta spell={spell} />
|
||||||
|
{spell.description ? (
|
||||||
|
<SpellDescription text={spell.description} />
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground italic">
|
||||||
|
No description available.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{spell.heightening ? (
|
||||||
|
<p className="whitespace-pre-line text-foreground text-xs">
|
||||||
|
{spell.heightening}
|
||||||
|
</p>
|
||||||
|
) : 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,
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -71,7 +71,9 @@ const FREE_ACTION_CHEVRON = "M48 27 L71 50 L48 73 Z";
|
|||||||
const REACTION_ARROW =
|
const REACTION_ARROW =
|
||||||
"M75 15 A42 42 0 1 0 85 55 L72 55 A30 30 0 1 1 65 25 L65 40 L92 20 L65 0 L65 15 Z";
|
"M75 15 A42 42 0 1 0 85 55 L72 55 A30 30 0 1 1 65 25 L65 40 L92 20 L65 0 L65 15 Z";
|
||||||
|
|
||||||
function ActivityIcon({ activity }: Readonly<{ activity: ActivityCost }>) {
|
export function ActivityIcon({
|
||||||
|
activity,
|
||||||
|
}: Readonly<{ activity: ActivityCost }>) {
|
||||||
const cls = "inline-block h-[1em] align-[-0.1em]";
|
const cls = "inline-block h-[1em] align-[-0.1em]";
|
||||||
if (activity.unit === "free") {
|
if (activity.unit === "free") {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -70,3 +70,72 @@ export function useSwipeToDismiss(onDismiss: () => void) {
|
|||||||
handlers: { onTouchStart, onTouchMove, onTouchEnd },
|
handlers: { onTouchStart, onTouchMove, onTouchEnd },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertical (down-only) variant for dismissing bottom sheets via swipe-down.
|
||||||
|
* Mirrors `useSwipeToDismiss` but locks to vertical direction and tracks
|
||||||
|
* the sheet height instead of width.
|
||||||
|
*/
|
||||||
|
export function useSwipeToDismissDown(onDismiss: () => void) {
|
||||||
|
const [swipe, setSwipe] = useState<SwipeState>({
|
||||||
|
offsetX: 0,
|
||||||
|
isSwiping: false,
|
||||||
|
});
|
||||||
|
const startX = useRef(0);
|
||||||
|
const startY = useRef(0);
|
||||||
|
const startTime = useRef(0);
|
||||||
|
const sheetHeight = useRef(0);
|
||||||
|
const directionLocked = useRef<"horizontal" | "vertical" | null>(null);
|
||||||
|
|
||||||
|
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
startX.current = touch.clientX;
|
||||||
|
startY.current = touch.clientY;
|
||||||
|
startTime.current = Date.now();
|
||||||
|
directionLocked.current = null;
|
||||||
|
const el = e.currentTarget as HTMLElement;
|
||||||
|
sheetHeight.current = el.getBoundingClientRect().height;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTouchMove = useCallback((e: React.TouchEvent) => {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const dx = touch.clientX - startX.current;
|
||||||
|
const dy = touch.clientY - startY.current;
|
||||||
|
|
||||||
|
if (!directionLocked.current) {
|
||||||
|
if (Math.abs(dx) < 10 && Math.abs(dy) < 10) return;
|
||||||
|
directionLocked.current =
|
||||||
|
Math.abs(dy) > Math.abs(dx) ? "vertical" : "horizontal";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (directionLocked.current === "horizontal") return;
|
||||||
|
|
||||||
|
const clampedY = Math.max(0, dy);
|
||||||
|
// `offsetX` is reused as the vertical offset to keep SwipeState shared.
|
||||||
|
setSwipe({ offsetX: clampedY, isSwiping: true });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTouchEnd = useCallback(() => {
|
||||||
|
if (directionLocked.current !== "vertical") {
|
||||||
|
setSwipe({ offsetX: 0, isSwiping: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = (Date.now() - startTime.current) / 1000;
|
||||||
|
const velocity = swipe.offsetX / elapsed / sheetHeight.current;
|
||||||
|
const ratio =
|
||||||
|
sheetHeight.current > 0 ? swipe.offsetX / sheetHeight.current : 0;
|
||||||
|
|
||||||
|
if (ratio > DISMISS_THRESHOLD || velocity > VELOCITY_THRESHOLD) {
|
||||||
|
onDismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSwipe({ offsetX: 0, isSwiping: false });
|
||||||
|
}, [swipe.offsetX, onDismiss]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
offsetY: swipe.offsetX,
|
||||||
|
isSwiping: swipe.isSwiping,
|
||||||
|
handlers: { onTouchStart, onTouchMove, onTouchEnd },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -103,6 +103,19 @@
|
|||||||
animation: slide-in-right 200ms ease-out;
|
animation: slide-in-right 200ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in-bottom {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility animate-slide-in-bottom {
|
||||||
|
animation: slide-in-bottom 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes confirm-pulse {
|
@keyframes confirm-pulse {
|
||||||
0% {
|
0% {
|
||||||
scale: 1;
|
scale: 1;
|
||||||
|
|||||||
@@ -31,16 +31,71 @@ export interface LegendaryBlock {
|
|||||||
readonly entries: readonly TraitBlock[];
|
readonly entries: readonly TraitBlock[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single spell entry within a creature's spellcasting block.
|
||||||
|
*
|
||||||
|
* `name` is always populated. All other fields are optional and are only
|
||||||
|
* populated for PF2e creatures (sourced from embedded Foundry VTT spell items).
|
||||||
|
* D&D 5e creatures populate only `name`.
|
||||||
|
*/
|
||||||
|
export interface SpellReference {
|
||||||
|
readonly name: string;
|
||||||
|
|
||||||
|
/** Stable slug from Foundry VTT (e.g. "magic-missile"). PF2e only. */
|
||||||
|
readonly slug?: string;
|
||||||
|
|
||||||
|
/** Plain-text description with Foundry enrichment tags stripped. */
|
||||||
|
readonly description?: string;
|
||||||
|
|
||||||
|
/** Spell rank/level (0 = cantrip). */
|
||||||
|
readonly rank?: number;
|
||||||
|
|
||||||
|
/** Trait slugs (e.g. ["concentrate", "manipulate", "force"]). */
|
||||||
|
readonly traits?: readonly string[];
|
||||||
|
|
||||||
|
/** Tradition labels (e.g. ["arcane", "occult"]). */
|
||||||
|
readonly traditions?: readonly string[];
|
||||||
|
|
||||||
|
/** Range (e.g. "30 feet", "touch"). */
|
||||||
|
readonly range?: string;
|
||||||
|
|
||||||
|
/** Target (e.g. "1 creature"). */
|
||||||
|
readonly target?: string;
|
||||||
|
|
||||||
|
/** Area (e.g. "20-foot burst"). */
|
||||||
|
readonly area?: string;
|
||||||
|
|
||||||
|
/** Duration (e.g. "1 minute", "sustained up to 1 minute"). */
|
||||||
|
readonly duration?: string;
|
||||||
|
|
||||||
|
/** Defense / save (e.g. "basic Reflex", "Will"). */
|
||||||
|
readonly defense?: string;
|
||||||
|
|
||||||
|
/** Action cost. PF2e: number = action count, "reaction", "free", or
|
||||||
|
* "1 minute" / "10 minutes" for cast time. */
|
||||||
|
readonly actionCost?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heightening rules text. May come from `system.heightening` (fixed
|
||||||
|
* intervals) or `system.overlays` (variant casts). Plain text after
|
||||||
|
* tag stripping.
|
||||||
|
*/
|
||||||
|
readonly heightening?: string;
|
||||||
|
|
||||||
|
/** Uses per day for "(×N)" rendering, when > 1. PF2e only. */
|
||||||
|
readonly usesPerDay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DailySpells {
|
export interface DailySpells {
|
||||||
readonly uses: number;
|
readonly uses: number;
|
||||||
readonly each: boolean;
|
readonly each: boolean;
|
||||||
readonly spells: readonly string[];
|
readonly spells: readonly SpellReference[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpellcastingBlock {
|
export interface SpellcastingBlock {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly headerText: string;
|
readonly headerText: string;
|
||||||
readonly atWill?: readonly string[];
|
readonly atWill?: readonly SpellReference[];
|
||||||
readonly daily?: readonly DailySpells[];
|
readonly daily?: readonly DailySpells[];
|
||||||
readonly restLong?: readonly DailySpells[];
|
readonly restLong?: readonly DailySpells[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export {
|
|||||||
type Pf2eCreature,
|
type Pf2eCreature,
|
||||||
proficiencyBonus,
|
proficiencyBonus,
|
||||||
type SpellcastingBlock,
|
type SpellcastingBlock,
|
||||||
|
type SpellReference,
|
||||||
type TraitBlock,
|
type TraitBlock,
|
||||||
type TraitListItem,
|
type TraitListItem,
|
||||||
type TraitSegment,
|
type TraitSegment,
|
||||||
|
|||||||
@@ -98,6 +98,11 @@ A view button in the search bar (repurposed from the current search icon) opens
|
|||||||
**US-D3 — Responsive Layout (P4)**
|
**US-D3 — Responsive Layout (P4)**
|
||||||
As a DM using the app on different devices, I want the layout to adapt between side-by-side (desktop) and drawer (mobile) so that the stat block is usable regardless of screen size.
|
As a DM using the app on different devices, I want the layout to adapt between side-by-side (desktop) and drawer (mobile) so that the stat block is usable regardless of screen size.
|
||||||
|
|
||||||
|
**US-D4 — View Spell Descriptions Inline (P2)**
|
||||||
|
As a DM running a PF2e encounter, I want to click a spell name in a creature's stat block to see the spell's full description without leaving the stat block, so I can quickly resolve what a spell does mid-combat without consulting external tools.
|
||||||
|
|
||||||
|
A click on any spell name in the spellcasting section opens a popover (desktop) or bottom sheet (mobile) showing the spell's description, level, traits, range, action cost, target/area, duration, defense/save, and heightening rules. The data is read directly from the cached creature data (already embedded in NPC JSON from Foundry VTT) — no additional network fetch is required, and the feature works offline once the source has been loaded. Dismiss with click-outside, Escape, or (on mobile) swipe-down.
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- **FR-016**: The system MUST display a stat block panel with full creature information when a creature is selected.
|
- **FR-016**: The system MUST display a stat block panel with full creature information when a creature is selected.
|
||||||
@@ -116,6 +121,13 @@ As a DM using the app on different devices, I want the layout to adapt between s
|
|||||||
- **FR-067**: PF2e stat blocks MUST organize abilities into three sections: top (above defenses), mid (reactions and auras), and bot (active abilities), matching the Foundry VTT PF2e item categorization.
|
- **FR-067**: PF2e stat blocks MUST organize abilities into three sections: top (above defenses), mid (reactions and auras), and bot (active abilities), matching the Foundry VTT PF2e item categorization.
|
||||||
- **FR-068**: PF2e stat blocks MUST strip HTML tags from Foundry VTT ability descriptions and render them as plain readable text. The HTML-to-text conversion serves the same role as the D&D tag-stripping approach (FR-019).
|
- **FR-068**: PF2e stat blocks MUST strip HTML tags from Foundry VTT ability descriptions and render them as plain readable text. The HTML-to-text conversion serves the same role as the D&D tag-stripping approach (FR-019).
|
||||||
- **FR-062**: Bestiary-linked combatant rows MUST display a small book icon (Lucide `BookOpen`) as the dedicated stat block trigger. The icon MUST have a tooltip ("View stat block") and an `aria-label="View stat block"` for accessibility. The book icon is the only way to manually open a stat block from the tracker — clicking the combatant name always enters inline rename mode. Non-bestiary combatant rows MUST NOT display the book icon.
|
- **FR-062**: Bestiary-linked combatant rows MUST display a small book icon (Lucide `BookOpen`) as the dedicated stat block trigger. The icon MUST have a tooltip ("View stat block") and an `aria-label="View stat block"` for accessibility. The book icon is the only way to manually open a stat block from the tracker — clicking the combatant name always enters inline rename mode. Non-bestiary combatant rows MUST NOT display the book icon.
|
||||||
|
- **FR-077**: PF2e stat blocks MUST render each spell name in the spellcasting section as an interactive element (clickable button), not as plain joined text.
|
||||||
|
- **FR-078**: Clicking a spell name MUST open a popover (desktop) or bottom sheet (mobile) displaying the spell's description, level, traits, range, time/actions, target/area, duration, defense/save, and heightening rules.
|
||||||
|
- **FR-079**: The spell description popover/sheet MUST render content from the spell data already embedded in the cached creature JSON — no additional network fetch is required.
|
||||||
|
- **FR-080**: The spell description popover/sheet MUST be dismissible by clicking outside, pressing Escape, or (on mobile) swiping the sheet down.
|
||||||
|
- **FR-081**: Spell descriptions MUST be processed through the existing Foundry tag-stripping utility before display (consistent with FR-068).
|
||||||
|
- **FR-082**: When a spell name has a parenthetical modifier (e.g., "Heal (×3)", "Unfettered Movement (Constant)"), only the spell name portion MUST be the click target; the modifier MUST remain as adjacent plain text.
|
||||||
|
- **FR-083**: The spell description display MUST handle both representations of heightening present in Foundry VTT data: `system.heightening` and `system.overlays`.
|
||||||
|
|
||||||
### Acceptance Scenarios
|
### Acceptance Scenarios
|
||||||
|
|
||||||
@@ -131,12 +143,19 @@ As a DM using the app on different devices, I want the layout to adapt between s
|
|||||||
10. **Given** a bestiary-linked combatant row is visible, **When** the user clicks the combatant's name, **Then** inline rename mode is entered — the stat block does NOT open.
|
10. **Given** a bestiary-linked combatant row is visible, **When** the user clicks the combatant's name, **Then** inline rename mode is entered — the stat block does NOT open.
|
||||||
11. **Given** a PF2e creature is selected, **When** the stat block opens, **Then** it displays: name, level, traits as tags, Perception, senses, languages, skills, ability modifiers, AC, Fort/Ref/Will saves, HP, speed, attacks, abilities (top/mid/bot sections), and spellcasting (if applicable). No CR, no ability scores, no legendary actions.
|
11. **Given** a PF2e creature is selected, **When** the stat block opens, **Then** it displays: name, level, traits as tags, Perception, senses, languages, skills, ability modifiers, AC, Fort/Ref/Will saves, HP, speed, attacks, abilities (top/mid/bot sections), and spellcasting (if applicable). No CR, no ability scores, no legendary actions.
|
||||||
12. **Given** a PF2e creature with conditional AC (e.g., "with shield raised"), **When** viewing the stat block, **Then** both the standard AC and conditional AC are shown.
|
12. **Given** a PF2e creature with conditional AC (e.g., "with shield raised"), **When** viewing the stat block, **Then** both the standard AC and conditional AC are shown.
|
||||||
|
13. **Given** a PF2e creature with spellcasting is displayed in the stat block panel, **When** the DM clicks a spell name in the spellcasting section, **Then** a popover (desktop) or bottom sheet (mobile) opens showing the spell's description, level, traits, range, action cost, and any heightening rules.
|
||||||
|
14. **Given** the spell description popover is open, **When** the DM clicks outside it or presses Escape, **Then** the popover dismisses.
|
||||||
|
15. **Given** the spell description bottom sheet is open on mobile, **When** the DM swipes the sheet down, **Then** the sheet dismisses.
|
||||||
|
16. **Given** a creature from a legacy (non-remastered) PF2e source has spells with pre-remaster names (e.g., "Magic Missile", "True Strike"), **When** the DM clicks one of those spell names, **Then** the spell description still displays correctly using the embedded data.
|
||||||
|
17. **Given** a spell name appears as "Heal (×3)" in the stat block, **When** the DM looks at the rendered output, **Then** "Heal" is the clickable element and "(×3)" appears as plain text next to it.
|
||||||
|
|
||||||
### Edge Cases
|
### Edge Cases
|
||||||
|
|
||||||
- Creatures with no traits or legendary actions: those sections are omitted from the stat block display.
|
- Creatures with no traits or legendary actions: those sections are omitted from the stat block display.
|
||||||
- Very long content (e.g., a Lich with extensive spellcasting): the stat block panel scrolls independently of the encounter tracker.
|
- Very long content (e.g., a Lich with extensive spellcasting): the stat block panel scrolls independently of the encounter tracker.
|
||||||
- Viewport resized from wide to narrow while stat block is open: the layout transitions from panel to drawer.
|
- Viewport resized from wide to narrow while stat block is open: the layout transitions from panel to drawer.
|
||||||
|
- Embedded spell item missing description text: the popover/sheet shows the available metadata (level, traits, range, etc.) and a placeholder note for the missing description.
|
||||||
|
- Cached source data from before the spell description feature was added: existing cached entries lack the new per-spell data fields. The IndexedDB schema version MUST be bumped to invalidate old caches and trigger re-fetch (re-normalization from raw Foundry data is not possible because the original raw JSON is not retained).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -197,6 +216,7 @@ A DM wants to see which sources are cached, find a specific source, clear a spec
|
|||||||
- **FR-074**: The PF2e index MUST exclude legacy/pre-remaster creatures based on the `publication.remaster` field — only remaster-era content is included by default.
|
- **FR-074**: The PF2e index MUST exclude legacy/pre-remaster creatures based on the `publication.remaster` field — only remaster-era content is included by default.
|
||||||
- **FR-075**: PF2e creature abilities MUST have complete descriptive text in stat blocks. Stubs, generic feat references, and unresolved copy entries are not acceptable.
|
- **FR-075**: PF2e creature abilities MUST have complete descriptive text in stat blocks. Stubs, generic feat references, and unresolved copy entries are not acceptable.
|
||||||
- **FR-076**: The PF2e index SHOULD carry per-creature license tagging (ORC/OGL) derived from the Foundry VTT source data.
|
- **FR-076**: The PF2e index SHOULD carry per-creature license tagging (ORC/OGL) derived from the Foundry VTT source data.
|
||||||
|
- **FR-084**: The PF2e normalization pipeline MUST preserve per-spell data (slug, level, traits, range, time, target, area, duration, defense, description, heightening/overlays) from embedded `items[type=spell]` entries on NPCs, in addition to the spell name. This data MUST be stored in the cached source data and persisted across browser sessions.
|
||||||
|
|
||||||
### Acceptance Scenarios
|
### Acceptance Scenarios
|
||||||
|
|
||||||
@@ -298,7 +318,7 @@ As a DM with a creature pinned, I want to collapse the right (browse) panel inde
|
|||||||
- **Search Index (D&D)** (`BestiaryIndex`): Pre-shipped lightweight dataset keyed by name + source, containing mechanical facts (name, source code, AC, HP average, DEX score, CR, initiative proficiency multiplier, size, type) for all creatures. Sufficient for adding combatants; insufficient for rendering a full stat block.
|
- **Search Index (D&D)** (`BestiaryIndex`): Pre-shipped lightweight dataset keyed by name + source, containing mechanical facts (name, source code, AC, HP average, DEX score, CR, initiative proficiency multiplier, size, type) for all creatures. Sufficient for adding combatants; insufficient for rendering a full stat block.
|
||||||
- **Search Index (PF2e)** (`Pf2eBestiaryIndex`): Pre-shipped lightweight dataset for PF2e creatures, containing name, source code, AC, HP, level, Perception modifier, size, and creature type. Parallel to the D&D search index but with PF2e-specific fields (level instead of CR, Perception instead of DEX/proficiency).
|
- **Search Index (PF2e)** (`Pf2eBestiaryIndex`): Pre-shipped lightweight dataset for PF2e creatures, containing name, source code, AC, HP, level, Perception modifier, size, and creature type. Parallel to the D&D search index but with PF2e-specific fields (level instead of CR, Perception instead of DEX/proficiency).
|
||||||
- **Source** (`BestiarySource`): A D&D or PF2e publication identified by a code (e.g., "XMM") with a display name (e.g., "Monster Manual (2025)"). Caching and fetching operate at the source level.
|
- **Source** (`BestiarySource`): A D&D or PF2e publication identified by a code (e.g., "XMM") with a display name (e.g., "Monster Manual (2025)"). Caching and fetching operate at the source level.
|
||||||
- **Creature (Full)** (`Creature`): A complete creature record with all stat block data (traits, actions, legendary actions, spellcasting, etc.), available only after source data is fetched/uploaded and cached. Identified by a branded `CreatureId`.
|
- **Creature (Full)** (`Creature`): A complete creature record with all stat block data (traits, actions, legendary actions, spellcasting, etc.), available only after source data is fetched/uploaded and cached. Identified by a branded `CreatureId`. For PF2e creatures, each spell entry inside `spellcasting` carries full per-spell data (slug, level, traits, range, action cost, target/area, duration, defense, description, heightening) extracted from the embedded `items[type=spell]` data on the source NPC, enabling inline spell description display without additional fetches.
|
||||||
- **Cached Source Data**: The full normalized bestiary data for a single source, stored in IndexedDB. Contains complete creature stat blocks.
|
- **Cached Source Data**: The full normalized bestiary data for a single source, stored in IndexedDB. Contains complete creature stat blocks.
|
||||||
- **Combatant** (extended): Gains an optional `creatureId` reference to a `Creature`, enabling stat block lookup and stat pre-fill on creation.
|
- **Combatant** (extended): Gains an optional `creatureId` reference to a `Creature`, enabling stat block lookup and stat pre-fill on creation.
|
||||||
- **Queued Creature**: Transient UI-only state representing a bestiary creature selected for batch-add, containing the creature reference and a count (1+). Not persisted.
|
- **Queued Creature**: Transient UI-only state representing a bestiary creature selected for batch-add, containing the creature reference and a count (1+). Not persisted.
|
||||||
@@ -331,3 +351,5 @@ As a DM with a creature pinned, I want to collapse the right (browse) panel inde
|
|||||||
- **SC-019**: PF2e stat blocks render the correct layout (level, three saves, ability modifiers, ability sections) for all PF2e creatures — no D&D-specific fields (CR, ability scores, legendary actions) are shown.
|
- **SC-019**: PF2e stat blocks render the correct layout (level, three saves, ability modifiers, ability sections) for all PF2e creatures — no D&D-specific fields (CR, ability scores, legendary actions) are shown.
|
||||||
- **SC-020**: Switching game system immediately changes which creatures appear in search — no page reload required.
|
- **SC-020**: Switching game system immediately changes which creatures appear in search — no page reload required.
|
||||||
- **SC-021**: Both D&D and PF2e search indexes ship bundled with the app; no network fetch is required to search creatures in either system.
|
- **SC-021**: Both D&D and PF2e search indexes ship bundled with the app; no network fetch is required to search creatures in either system.
|
||||||
|
- **SC-022**: Clicking any spell in a PF2e creature's stat block opens its description display within 100ms — no network I/O is performed.
|
||||||
|
- **SC-023**: PF2e spell descriptions are available offline once the bestiary source containing the creature has been cached.
|
||||||
|
|||||||
Reference in New Issue
Block a user