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?.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" }],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) })),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -4,11 +4,14 @@ import "@testing-library/jest-dom/vitest";
|
||||
import type { Pf2eCreature } from "@initiative/domain";
|
||||
import { creatureId } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Pf2eStatBlock } from "../pf2e-stat-block.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const USES_PER_DAY_REGEX = /×3/;
|
||||
const HEAL_DESCRIPTION_REGEX = /channel positive energy/;
|
||||
const PERCEPTION_SENSES_REGEX = /\+2.*Darkvision/;
|
||||
const SKILLS_REGEX = /Acrobatics \+5.*Stealth \+5/;
|
||||
const SAVE_CONDITIONAL_REGEX = /\+12.*\+1 status to all saves vs\. magic/;
|
||||
@@ -90,9 +93,13 @@ const NAUNET: Pf2eCreature = {
|
||||
name: "Divine Innate Spells",
|
||||
headerText: "DC 25, attack +17",
|
||||
daily: [
|
||||
{ uses: 4, each: true, spells: ["Unfettered Movement (Constant)"] },
|
||||
{
|
||||
uses: 4,
|
||||
each: true,
|
||||
spells: [{ name: "Unfettered Movement (Constant)" }],
|
||||
},
|
||||
],
|
||||
atWill: ["Detect Magic"],
|
||||
atWill: [{ name: "Detect Magic" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -277,4 +284,87 @@ describe("Pf2eStatBlock", () => {
|
||||
expect(screen.queryByText(CANTRIPS_REGEX)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clickable spells", () => {
|
||||
const SPELLCASTER: Pf2eCreature = {
|
||||
...NAUNET,
|
||||
id: creatureId("test:spellcaster"),
|
||||
name: "Spellcaster",
|
||||
spellcasting: [
|
||||
{
|
||||
name: "Divine Innate Spells",
|
||||
headerText: "DC 30, attack +20",
|
||||
atWill: [{ name: "Detect Magic", rank: 1 }],
|
||||
daily: [
|
||||
{
|
||||
uses: 4,
|
||||
each: true,
|
||||
spells: [
|
||||
{
|
||||
name: "Heal",
|
||||
description: "You channel positive energy to heal.",
|
||||
rank: 4,
|
||||
usesPerDay: 3,
|
||||
},
|
||||
{ name: "Restoration", rank: 4 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis, "matchMedia", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: vi.fn().mockImplementation(() => ({
|
||||
matches: true,
|
||||
media: "(min-width: 1024px)",
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a spell with a description as a clickable button", () => {
|
||||
renderStatBlock(SPELLCASTER);
|
||||
expect(screen.getByRole("button", { name: "Heal" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a spell without description as plain text (not a button)", () => {
|
||||
renderStatBlock(SPELLCASTER);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Restoration" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Restoration")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders usesPerDay as plain text alongside the spell button", () => {
|
||||
renderStatBlock(SPELLCASTER);
|
||||
expect(screen.getByText(USES_PER_DAY_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the spell popover when a spell button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderStatBlock(SPELLCASTER);
|
||||
await user.click(screen.getByRole("button", { name: "Heal" }));
|
||||
expect(screen.getByText(HEAL_DESCRIPTION_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes the popover when Escape is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderStatBlock(SPELLCASTER);
|
||||
await user.click(screen.getByRole("button", { name: "Heal" }));
|
||||
expect(screen.getByText(HEAL_DESCRIPTION_REGEX)).toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
expect(
|
||||
screen.queryByText(HEAL_DESCRIPTION_REGEX),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
headerText: "The dragon's spellcasting ability is Charisma.",
|
||||
atWill: ["detect magic", "suggestion"],
|
||||
daily: [{ uses: 3, each: true, spells: ["fireball", "wall of fire"] }],
|
||||
restLong: [{ uses: 1, each: false, spells: ["wish"] }],
|
||||
atWill: [{ name: "detect magic" }, { name: "suggestion" }],
|
||||
daily: [
|
||||
{
|
||||
uses: 3,
|
||||
each: true,
|
||||
spells: [{ name: "fireball" }, { name: "wall of fire" }],
|
||||
},
|
||||
],
|
||||
restLong: [{ uses: 1, each: false, spells: [{ name: "wish" }] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -134,7 +134,7 @@ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
||||
{sc.atWill && sc.atWill.length > 0 && (
|
||||
<div className="pl-2">
|
||||
<span className="font-semibold">At Will:</span>{" "}
|
||||
{sc.atWill.join(", ")}
|
||||
{sc.atWill.map((s) => s.name).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{sc.daily?.map((d) => (
|
||||
@@ -143,7 +143,7 @@ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
||||
{d.uses}/day
|
||||
{d.each ? " each" : ""}:
|
||||
</span>{" "}
|
||||
{d.spells.join(", ")}
|
||||
{d.spells.map((s) => s.name).join(", ")}
|
||||
</div>
|
||||
))}
|
||||
{sc.restLong?.map((d) => (
|
||||
@@ -155,7 +155,7 @@ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
||||
{d.uses}/long rest
|
||||
{d.each ? " each" : ""}:
|
||||
</span>{" "}
|
||||
{d.spells.join(", ")}
|
||||
{d.spells.map((s) => s.name).join(", ")}
|
||||
</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 { useCallback, useRef, useState } from "react";
|
||||
import { SpellDetailPopover } from "./spell-detail-popover.js";
|
||||
import {
|
||||
PropertyLine,
|
||||
SectionDivider,
|
||||
@@ -34,7 +36,83 @@ function formatMod(mod: number): string {
|
||||
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>) {
|
||||
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 = [
|
||||
{ label: "Str", mod: creature.abilityMods.str },
|
||||
{ label: "Dex", mod: creature.abilityMods.dex },
|
||||
@@ -152,23 +230,31 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||
{sc.headerText}
|
||||
</div>
|
||||
{sc.daily?.map((d) => (
|
||||
<div key={d.uses} className="pl-2">
|
||||
<span className="font-semibold">
|
||||
{d.uses === 0 ? "Cantrips" : `Rank ${d.uses}`}:
|
||||
</span>{" "}
|
||||
{d.spells.join(", ")}
|
||||
</div>
|
||||
<SpellListLine
|
||||
key={d.uses}
|
||||
label={d.uses === 0 ? "Cantrips" : `Rank ${d.uses}`}
|
||||
spells={d.spells}
|
||||
onOpen={handleOpenSpell}
|
||||
/>
|
||||
))}
|
||||
{sc.atWill && sc.atWill.length > 0 && (
|
||||
<div className="pl-2">
|
||||
<span className="font-semibold">Cantrips:</span>{" "}
|
||||
{sc.atWill.join(", ")}
|
||||
</div>
|
||||
<SpellListLine
|
||||
label="Cantrips"
|
||||
spells={sc.atWill}
|
||||
onOpen={handleOpenSpell}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{openSpell ? (
|
||||
<SpellDetailPopover
|
||||
spell={openSpell.spell}
|
||||
anchorRect={openSpell.rect}
|
||||
onClose={handleCloseSpell}
|
||||
/>
|
||||
) : null}
|
||||
</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 =
|
||||
"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]";
|
||||
if (activity.unit === "free") {
|
||||
return (
|
||||
|
||||
@@ -70,3 +70,72 @@ export function useSwipeToDismiss(onDismiss: () => void) {
|
||||
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;
|
||||
}
|
||||
|
||||
@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 {
|
||||
0% {
|
||||
scale: 1;
|
||||
|
||||
Reference in New Issue
Block a user