+ );
+}
+
+export function SpellDetailPopover({
+ spell,
+ anchorRect,
+ onClose,
+}: Readonly) {
+ 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 ? (
+
+ ) : (
+
+ );
+ return createPortal(content, document.body);
+}
diff --git a/apps/web/src/components/stat-block-parts.tsx b/apps/web/src/components/stat-block-parts.tsx
index c56c51f..0056a48 100644
--- a/apps/web/src/components/stat-block-parts.tsx
+++ b/apps/web/src/components/stat-block-parts.tsx
@@ -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 (
diff --git a/apps/web/src/hooks/use-swipe-to-dismiss.ts b/apps/web/src/hooks/use-swipe-to-dismiss.ts
index a414c5e..e552762 100644
--- a/apps/web/src/hooks/use-swipe-to-dismiss.ts
+++ b/apps/web/src/hooks/use-swipe-to-dismiss.ts
@@ -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({
+ 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 },
+ };
+}
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 0aef3aa..7507dc3 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -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;
diff --git a/packages/domain/src/creature-types.ts b/packages/domain/src/creature-types.ts
index 199f48c..548bf8e 100644
--- a/packages/domain/src/creature-types.ts
+++ b/packages/domain/src/creature-types.ts
@@ -31,16 +31,71 @@ export interface LegendaryBlock {
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 {
readonly uses: number;
readonly each: boolean;
- readonly spells: readonly string[];
+ readonly spells: readonly SpellReference[];
}
export interface SpellcastingBlock {
readonly name: string;
readonly headerText: string;
- readonly atWill?: readonly string[];
+ readonly atWill?: readonly SpellReference[];
readonly daily?: readonly DailySpells[];
readonly restLong?: readonly DailySpells[];
}
diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts
index e8e3dc9..3d9cbc0 100644
--- a/packages/domain/src/index.ts
+++ b/packages/domain/src/index.ts
@@ -39,6 +39,7 @@ export {
type Pf2eCreature,
proficiencyBonus,
type SpellcastingBlock,
+ type SpellReference,
type TraitBlock,
type TraitListItem,
type TraitSegment,
diff --git a/specs/004-bestiary/spec.md b/specs/004-bestiary/spec.md
index 751249f..e773bd4 100644
--- a/specs/004-bestiary/spec.md
+++ b/specs/004-bestiary/spec.md
@@ -98,6 +98,11 @@ A view button in the search bar (repurposed from the current search icon) opens
**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.
+**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
- **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-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-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
@@ -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.
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.
+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
- 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.
- 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-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-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
@@ -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 (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.
-- **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.
- **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.
@@ -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-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-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.