Implement stat block panel fold/unfold and pin-to-second-panel

Replace the close button and heading with fold/unfold controls that
collapse the panel to a slim right-edge tab showing the creature name
vertically, and add a pin button (xl+ viewports with creature loaded)
that opens the creature in a second left-side panel for simultaneous
reference. Fold state is respected on turn change. 19 acceptance tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-11 14:18:15 +01:00
parent 95cb2edc23
commit 460c65bf49
12 changed files with 1165 additions and 57 deletions

View File

@@ -86,6 +86,8 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
- TypeScript 5.8, CSS (Tailwind CSS v4) + React 19, Tailwind CSS v4 (033-fix-concentration-glow-clip)
- N/A (no persistence changes) (033-fix-concentration-glow-clip)
- N/A (no persistence changes — display-only refactor) (034-topbar-redesign)
- TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Tailwind CSS v4, Lucide React (icons), class-variance-authority (035-statblock-fold-pin)
- N/A (no persistence changes — all new state is ephemeral) (035-statblock-fold-pin)
## Recent Changes
- 032-inline-confirm-buttons: Added TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Tailwind CSS v4, Lucide React, class-variance-authority (cva)

View File

@@ -53,11 +53,29 @@ export function App() {
useState<CreatureId | null>(null);
const [bulkImportMode, setBulkImportMode] = useState(false);
const [sourceManagerOpen, setSourceManagerOpen] = useState(false);
const [isRightPanelFolded, setIsRightPanelFolded] = useState(false);
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
null,
);
const [isWideDesktop, setIsWideDesktop] = useState(
() => window.matchMedia("(min-width: 1280px)").matches,
);
useEffect(() => {
const mq = window.matchMedia("(min-width: 1280px)");
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const selectedCreature: Creature | null = selectedCreatureId
? (getCreature(selectedCreatureId) ?? null)
: null;
const pinnedCreature: Creature | null = pinnedCreatureId
? (getCreature(pinnedCreatureId) ?? null)
: null;
const handleAddFromBestiary = useCallback(
(result: SearchResult) => {
addFromBestiary(result);
@@ -75,6 +93,7 @@ export function App() {
const handleCombatantStatBlock = useCallback((creatureId: string) => {
setSelectedCreatureId(creatureId as CreatureId);
setIsRightPanelFolded(false);
}, []);
const handleRollInitiative = useCallback(
@@ -110,6 +129,25 @@ export function App() {
bulkImport.reset();
}, [bulkImport.reset]);
const handleDismissBrowsePanel = useCallback(() => {
setSelectedCreatureId(null);
setBulkImportMode(false);
}, []);
const handleToggleFold = useCallback(() => {
setIsRightPanelFolded((f) => !f);
}, []);
const handlePin = useCallback(() => {
if (selectedCreatureId) {
setPinnedCreatureId(selectedCreatureId);
}
}, [selectedCreatureId]);
const handleUnpin = useCallback(() => {
setPinnedCreatureId(null);
}, []);
// Auto-scroll to the active combatant when the turn changes
const activeRowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -202,7 +240,27 @@ export function App() {
</div>
</div>
{/* Stat Block Panel */}
{/* Pinned Stat Block Panel (left) */}
{pinnedCreatureId && isWideDesktop && (
<StatBlockPanel
creatureId={pinnedCreatureId}
creature={pinnedCreature}
isSourceCached={isSourceCached}
fetchAndCacheSource={fetchAndCacheSource}
uploadAndCacheSource={uploadAndCacheSource}
refreshCache={refreshCache}
panelRole="pinned"
isFolded={false}
onToggleFold={() => {}}
onPin={() => {}}
onUnpin={handleUnpin}
showPinButton={false}
side="left"
onDismiss={() => {}}
/>
)}
{/* Browse Stat Block Panel (right) */}
<StatBlockPanel
creatureId={selectedCreatureId}
creature={selectedCreature}
@@ -210,10 +268,14 @@ export function App() {
fetchAndCacheSource={fetchAndCacheSource}
uploadAndCacheSource={uploadAndCacheSource}
refreshCache={refreshCache}
onClose={() => {
setSelectedCreatureId(null);
setBulkImportMode(false);
}}
panelRole="browse"
isFolded={isRightPanelFolded}
onToggleFold={handleToggleFold}
onPin={handlePin}
onUnpin={() => {}}
showPinButton={isWideDesktop && !!selectedCreature}
side="right"
onDismiss={handleDismissBrowsePanel}
bulkImportMode={bulkImportMode}
bulkImportState={bulkImport.state}
onStartBulkImport={handleStartBulkImport}

View File

@@ -0,0 +1,263 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import type { Creature, CreatureId } from "@initiative/domain";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StatBlockPanel } from "../components/stat-block-panel";
const CREATURE_ID = "srd:goblin" as CreatureId;
const CREATURE: Creature = {
id: CREATURE_ID,
name: "Goblin",
source: "SRD",
sourceDisplayName: "SRD",
size: "Small",
type: "humanoid",
alignment: "neutral evil",
ac: 15,
hp: { average: 7, formula: "2d6" },
speed: "30 ft.",
abilities: { str: 8, dex: 14, con: 10, int: 10, wis: 8, cha: 8 },
cr: "1/4",
initiativeProficiency: 0,
proficiencyBonus: 2,
passive: 9,
};
function mockMatchMedia(matches: boolean) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
interface PanelProps {
creatureId?: CreatureId | null;
creature?: Creature | null;
panelRole?: "browse" | "pinned";
isFolded?: boolean;
onToggleFold?: () => void;
onPin?: () => void;
onUnpin?: () => void;
showPinButton?: boolean;
side?: "left" | "right";
onDismiss?: () => void;
bulkImportMode?: boolean;
}
function renderPanel(overrides: PanelProps = {}) {
const props = {
creatureId: CREATURE_ID,
creature: CREATURE,
isSourceCached: vi.fn().mockResolvedValue(true),
fetchAndCacheSource: vi.fn(),
uploadAndCacheSource: vi.fn(),
refreshCache: vi.fn(),
panelRole: "browse" as const,
isFolded: false,
onToggleFold: vi.fn(),
onPin: vi.fn(),
onUnpin: vi.fn(),
showPinButton: false,
side: "right" as const,
onDismiss: vi.fn(),
...overrides,
};
render(<StatBlockPanel {...props} />);
return props;
}
describe("Stat Block Panel Fold/Unfold and Pin", () => {
beforeEach(() => {
mockMatchMedia(true); // desktop by default
});
afterEach(cleanup);
describe("US1: Fold and Unfold", () => {
it("shows fold button instead of close button on desktop", () => {
renderPanel();
expect(
screen.getByRole("button", { name: "Fold stat block panel" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /close/i }),
).not.toBeInTheDocument();
});
it("does not show 'Stat Block' heading", () => {
renderPanel();
expect(screen.queryByText("Stat Block")).not.toBeInTheDocument();
});
it("renders folded tab with creature name when isFolded is true", () => {
renderPanel({ isFolded: true });
expect(screen.getByText("Goblin")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Unfold stat block panel" }),
).toBeInTheDocument();
});
it("calls onToggleFold when fold button is clicked", () => {
const props = renderPanel();
fireEvent.click(
screen.getByRole("button", { name: "Fold stat block panel" }),
);
expect(props.onToggleFold).toHaveBeenCalledTimes(1);
});
it("calls onToggleFold when folded tab is clicked", () => {
const props = renderPanel({ isFolded: true });
fireEvent.click(
screen.getByRole("button", { name: "Unfold stat block panel" }),
);
expect(props.onToggleFold).toHaveBeenCalledTimes(1);
});
it("applies translate-x class when folded (right side)", () => {
renderPanel({ isFolded: true, side: "right" });
const panel = screen
.getByRole("button", { name: "Unfold stat block panel" })
.closest("div");
expect(panel?.className).toContain("translate-x-[calc(100%-40px)]");
});
it("applies translate-x-0 when expanded", () => {
renderPanel({ isFolded: false });
const foldBtn = screen.getByRole("button", {
name: "Fold stat block panel",
});
const panel = foldBtn.closest("div.fixed") as HTMLElement;
expect(panel?.className).toContain("translate-x-0");
});
});
describe("US1: Mobile behavior", () => {
beforeEach(() => {
mockMatchMedia(false); // mobile
});
it("shows fold button instead of X close button on mobile drawer", () => {
renderPanel();
expect(
screen.getByRole("button", { name: "Fold stat block panel" }),
).toBeInTheDocument();
// No X close icon button — only backdrop dismiss and fold toggle
const buttons = screen.getAllByRole("button");
const buttonLabels = buttons.map((b) => b.getAttribute("aria-label"));
expect(buttonLabels).not.toContain("Close");
});
it("calls onDismiss when backdrop is clicked on mobile", () => {
const props = renderPanel();
fireEvent.click(screen.getByRole("button", { name: "Close stat block" }));
expect(props.onDismiss).toHaveBeenCalledTimes(1);
});
it("does not render pinned panel on mobile", () => {
const { container } = render(
<StatBlockPanel
creatureId={CREATURE_ID}
creature={CREATURE}
isSourceCached={vi.fn().mockResolvedValue(true)}
fetchAndCacheSource={vi.fn()}
uploadAndCacheSource={vi.fn()}
refreshCache={vi.fn()}
panelRole="pinned"
isFolded={false}
onToggleFold={vi.fn()}
onPin={vi.fn()}
onUnpin={vi.fn()}
showPinButton={false}
side="left"
onDismiss={vi.fn()}
/>,
);
expect(container.innerHTML).toBe("");
});
});
describe("US2: Pin and Unpin", () => {
it("shows pin button when showPinButton is true on desktop", () => {
renderPanel({ showPinButton: true });
expect(
screen.getByRole("button", { name: "Pin creature" }),
).toBeInTheDocument();
});
it("hides pin button when showPinButton is false", () => {
renderPanel({ showPinButton: false });
expect(
screen.queryByRole("button", { name: "Pin creature" }),
).not.toBeInTheDocument();
});
it("calls onPin when pin button is clicked", () => {
const props = renderPanel({ showPinButton: true });
fireEvent.click(screen.getByRole("button", { name: "Pin creature" }));
expect(props.onPin).toHaveBeenCalledTimes(1);
});
it("shows unpin button for pinned role", () => {
renderPanel({ panelRole: "pinned", side: "left" });
expect(
screen.getByRole("button", { name: "Unpin creature" }),
).toBeInTheDocument();
});
it("calls onUnpin when unpin button is clicked", () => {
const props = renderPanel({ panelRole: "pinned", side: "left" });
fireEvent.click(screen.getByRole("button", { name: "Unpin creature" }));
expect(props.onUnpin).toHaveBeenCalledTimes(1);
});
it("positions pinned panel on the left side", () => {
renderPanel({ panelRole: "pinned", side: "left" });
const unpinBtn = screen.getByRole("button", {
name: "Unpin creature",
});
const panel = unpinBtn.closest("div.fixed") as HTMLElement;
expect(panel?.className).toContain("left-0");
expect(panel?.className).toContain("border-r");
});
it("positions browse panel on the right side", () => {
renderPanel({ panelRole: "browse", side: "right" });
const foldBtn = screen.getByRole("button", {
name: "Fold stat block panel",
});
const panel = foldBtn.closest("div.fixed") as HTMLElement;
expect(panel?.className).toContain("right-0");
expect(panel?.className).toContain("border-l");
});
});
describe("US3: Fold independence with pinned panel", () => {
it("pinned panel has no fold button", () => {
renderPanel({ panelRole: "pinned", side: "left" });
expect(
screen.queryByRole("button", { name: /fold/i }),
).not.toBeInTheDocument();
});
it("pinned panel is always expanded (no translate offset)", () => {
renderPanel({ panelRole: "pinned", side: "left", isFolded: false });
const unpinBtn = screen.getByRole("button", {
name: "Unpin creature",
});
const panel = unpinBtn.closest("div.fixed") as HTMLElement;
expect(panel?.className).toContain("translate-x-0");
});
});
});

View File

@@ -1,5 +1,6 @@
import type { Creature, CreatureId } from "@initiative/domain";
import { X } from "lucide-react";
import { PanelRightClose, Pin, PinOff } from "lucide-react";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
import type { BulkImportState } from "../hooks/use-bulk-import.js";
@@ -17,7 +18,14 @@ interface StatBlockPanelProps {
jsonData: unknown,
) => Promise<void>;
refreshCache: () => Promise<void>;
onClose: () => void;
panelRole: "browse" | "pinned";
isFolded: boolean;
onToggleFold: () => void;
onPin: () => void;
onUnpin: () => void;
showPinButton: boolean;
side: "left" | "right";
onDismiss: () => void;
bulkImportMode?: boolean;
bulkImportState?: BulkImportState;
onStartBulkImport?: (baseUrl: string) => void;
@@ -30,6 +38,171 @@ function extractSourceCode(cId: CreatureId): string {
return cId.slice(0, colonIndex).toUpperCase();
}
function FoldedTab({
creatureName,
side,
onToggleFold,
}: {
creatureName: string;
side: "left" | "right";
onToggleFold: () => void;
}) {
return (
<button
type="button"
onClick={onToggleFold}
className={`flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral ${
side === "right" ? "self-start" : "self-end"
}`}
aria-label="Unfold stat block panel"
>
<span className="writing-vertical-rl text-sm font-medium">
{creatureName}
</span>
</button>
);
}
function PanelHeader({
panelRole,
showPinButton,
onToggleFold,
onPin,
onUnpin,
}: {
panelRole: "browse" | "pinned";
showPinButton: boolean;
onToggleFold: () => void;
onPin: () => void;
onUnpin: () => void;
}) {
return (
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex items-center gap-1">
{panelRole === "browse" && (
<button
type="button"
onClick={onToggleFold}
className="text-muted-foreground hover:text-hover-neutral"
aria-label="Fold stat block panel"
>
<PanelRightClose className="h-4 w-4" />
</button>
)}
</div>
<div className="flex items-center gap-1">
{panelRole === "browse" && showPinButton && (
<button
type="button"
onClick={onPin}
className="text-muted-foreground hover:text-hover-neutral"
aria-label="Pin creature"
>
<Pin className="h-4 w-4" />
</button>
)}
{panelRole === "pinned" && (
<button
type="button"
onClick={onUnpin}
className="text-muted-foreground hover:text-hover-neutral"
aria-label="Unpin creature"
>
<PinOff className="h-4 w-4" />
</button>
)}
</div>
</div>
);
}
function DesktopPanel({
isFolded,
side,
creatureName,
panelRole,
showPinButton,
onToggleFold,
onPin,
onUnpin,
children,
}: {
isFolded: boolean;
side: "left" | "right";
creatureName: string;
panelRole: "browse" | "pinned";
showPinButton: boolean;
onToggleFold: () => void;
onPin: () => void;
onUnpin: () => void;
children: ReactNode;
}) {
const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l";
const foldedTranslate =
side === "right"
? "translate-x-[calc(100%-40px)]"
: "translate-x-[calc(-100%+40px)]";
return (
<div
className={`fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel ${sideClasses} ${isFolded ? foldedTranslate : "translate-x-0"}`}
>
{isFolded ? (
<FoldedTab
creatureName={creatureName}
side={side}
onToggleFold={onToggleFold}
/>
) : (
<>
<PanelHeader
panelRole={panelRole}
showPinButton={showPinButton}
onToggleFold={onToggleFold}
onPin={onPin}
onUnpin={onUnpin}
/>
<div className="flex-1 overflow-y-auto p-4">{children}</div>
</>
)}
</div>
);
}
function MobileDrawer({
onDismiss,
children,
}: {
onDismiss: () => void;
children: ReactNode;
}) {
return (
<div className="fixed inset-0 z-50">
<button
type="button"
className="absolute inset-0 bg-black/50 animate-in fade-in"
onClick={onDismiss}
aria-label="Close stat block"
/>
<div className="absolute top-0 right-0 bottom-0 w-[85%] max-w-md animate-slide-in-right border-l border-border bg-card shadow-xl">
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<button
type="button"
onClick={onDismiss}
className="text-muted-foreground hover:text-hover-neutral"
aria-label="Fold stat block panel"
>
<PanelRightClose className="h-4 w-4" />
</button>
</div>
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
{children}
</div>
</div>
</div>
);
}
export function StatBlockPanel({
creatureId,
creature,
@@ -37,7 +210,14 @@ export function StatBlockPanel({
fetchAndCacheSource,
uploadAndCacheSource,
refreshCache,
onClose,
panelRole,
isFolded,
onToggleFold,
onPin,
onUnpin,
showPinButton,
side,
onDismiss,
bulkImportMode,
bulkImportState,
onStartBulkImport,
@@ -56,7 +236,6 @@ export function StatBlockPanel({
return () => mq.removeEventListener("change", handler);
}, []);
// When creatureId changes, check if we need to show the fetch prompt
useEffect(() => {
if (!creatureId || creature) {
setNeedsFetch(false);
@@ -71,8 +250,6 @@ export function StatBlockPanel({
setCheckingCache(true);
isSourceCached(sourceCode).then((cached) => {
// If source is cached but creature not found, it's an edge case
// If source is not cached, show fetch prompt
setNeedsFetch(!cached);
setCheckingCache(false);
});
@@ -132,56 +309,27 @@ export function StatBlockPanel({
);
};
const panelTitle = bulkImportMode ? "Bulk Import" : "Stat Block";
const creatureName =
creature?.name ?? (bulkImportMode ? "Bulk Import" : "Creature");
if (isDesktop) {
return (
<div className="fixed top-0 right-0 bottom-0 flex w-[400px] flex-col border-l border-border bg-card">
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<span className="text-sm font-semibold text-muted-foreground">
{panelTitle}
</span>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-hover-neutral"
<DesktopPanel
isFolded={isFolded}
side={side}
creatureName={creatureName}
panelRole={panelRole}
showPinButton={showPinButton}
onToggleFold={onToggleFold}
onPin={onPin}
onUnpin={onUnpin}
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">{renderContent()}</div>
</div>
{renderContent()}
</DesktopPanel>
);
}
// Mobile drawer
return (
<div className="fixed inset-0 z-50">
{/* Backdrop */}
<button
type="button"
className="absolute inset-0 bg-black/50 animate-in fade-in"
onClick={onClose}
aria-label="Close stat block"
/>
{/* Drawer */}
<div className="absolute top-0 right-0 bottom-0 w-[85%] max-w-md animate-slide-in-right border-l border-border bg-card shadow-xl">
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<span className="text-sm font-semibold text-muted-foreground">
{panelTitle}
</span>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-hover-neutral"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
{renderContent()}
</div>
</div>
</div>
);
if (panelRole === "pinned") return null;
return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
}

View File

@@ -84,6 +84,14 @@
animation: confirm-pulse 300ms ease-out;
}
@utility transition-slide-panel {
transition: translate 200ms ease-out;
}
@utility writing-vertical-rl {
writing-mode: vertical-rl;
}
@utility animate-concentration-pulse {
animation:
concentration-shake 450ms ease-out,

View File

@@ -0,0 +1,36 @@
# Specification Quality Checklist: Stat Block Panel Fold/Unfold and Pin
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-11
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
- The spec mentions "~200ms ease-out" which is a design constraint from the original issue, not an implementation detail — it describes the user-perceived behavior.
- CSS animation timing is retained as a UX specification, not a technology choice.

View File

@@ -0,0 +1,62 @@
# Data Model: Stat Block Panel Fold/Unfold and Pin
**Feature**: 035-statblock-fold-pin
**Date**: 2026-03-11
## Entities
### Panel State
This feature introduces UI-only ephemeral state — no persistence changes, no domain model changes.
| Field | Type | Description |
|-------|------|-------------|
| `selectedCreatureId` | `CreatureId \| null` | Currently browsed creature (right panel). **Exists already.** |
| `pinnedCreatureId` | `CreatureId \| null` | Creature locked to left panel. **New.** |
| `isRightPanelFolded` | `boolean` | Whether the right (browse) panel is collapsed to a tab. **New.** |
### State Transitions
```
No panel → Open panel
Trigger: Click combatant row / auto-show on turn change
Effect: selectedCreatureId = creatureId, isRightPanelFolded = false
Open panel → Folded tab
Trigger: Click fold button
Effect: isRightPanelFolded = true (selectedCreatureId preserved)
Folded tab → Open panel
Trigger: Click folded tab
Effect: isRightPanelFolded = false
Open panel → Open panel + Pinned panel
Trigger: Click pin button (xl+ viewport only)
Effect: pinnedCreatureId = selectedCreatureId
Pinned panel → No pinned panel
Trigger: Click unpin button
Effect: pinnedCreatureId = null
Open panel (mobile) → Dismissed
Trigger: Click backdrop
Effect: selectedCreatureId = null
```
### Viewport Breakpoints
| Breakpoint | Panels Available | Pin Button |
|------------|-----------------|------------|
| < 1024px (mobile) | Mobile drawer only | Hidden |
| 1024px1279px (lg) | Right panel only | Hidden |
| >= 1280px (xl) | Right + optional left pinned | Visible |
## Existing Entities (Unchanged)
- **Creature** (`CreatureId`, `name`, stats…) — read-only, no changes
- **Encounter state** — no changes
- **Bestiary cache** — no changes
## Persistence
No persistence changes. All new state (pinnedCreatureId, isRightPanelFolded) is ephemeral React component state that resets on page reload. This aligns with the existing pattern where selectedCreatureId is also ephemeral.

View File

@@ -0,0 +1,127 @@
# Implementation Plan: Stat Block Panel Fold/Unfold and Pin
**Branch**: `035-statblock-fold-pin` | **Date**: 2026-03-11 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/035-statblock-fold-pin/spec.md`
## Summary
Replace the stat block panel's close button and heading with fold/unfold controls that collapse the panel to a slim right-edge tab (with smooth CSS slide animation), and add a pin button that opens the creature in a second left-side panel for simultaneous reference while browsing other creatures in the right panel. All changes are UI-layer only with ephemeral state.
## Technical Context
**Language/Version**: TypeScript 5.8 (strict mode, `verbatimModuleSyntax`)
**Primary Dependencies**: React 19, Tailwind CSS v4, Lucide React (icons), class-variance-authority
**Storage**: N/A (no persistence changes — all new state is ephemeral)
**Testing**: Vitest + @testing-library/react
**Target Platform**: Web (desktop + mobile browsers)
**Project Type**: Web application (React SPA)
**Performance Goals**: Fold/unfold animation completes in ~200ms; no layout shifts or jank
**Constraints**: Dual panels require xl (1280px+) viewport; mobile drawer behavior preserved unchanged
**Scale/Scope**: 3 files modified, 0 new files, ~150-200 lines changed
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Notes |
|-----------|--------|-------|
| I. Deterministic Domain Core | PASS | No domain changes. All changes are in the adapter (web) layer. |
| II. Layered Architecture | PASS | Changes confined to `apps/web/` (adapter layer). No imports from inner layers are added or modified. |
| III. Clarification-First | PASS | Spec is complete with no NEEDS CLARIFICATION markers. Research resolved all design decisions. |
| IV. Escalation Gates | PASS | All requirements trace to spec.md acceptance criteria. No scope creep. |
| V. MVP Baseline Language | PASS | No permanent bans introduced. |
| VI. No Gameplay Rules | PASS | Feature is UI chrome, not gameplay. |
**Post-Phase 1 Re-check**: All gates still pass. No domain or application layer changes introduced in design. Data model is ephemeral UI state only.
## Project Structure
### Documentation (this feature)
```text
specs/035-statblock-fold-pin/
├── spec.md # Feature specification
├── plan.md # This file
├── research.md # Phase 0: design decisions and rationale
├── data-model.md # Phase 1: state model and transitions
├── quickstart.md # Phase 1: developer onboarding
└── checklists/
└── requirements.md # Spec quality checklist
```
### Source Code (repository root)
```text
apps/web/src/
├── components/
│ ├── stat-block-panel.tsx # MODIFY: fold/unfold, pin/unpin, remove close/heading
│ └── stat-block.tsx # NO CHANGE (display component)
├── App.tsx # MODIFY: new state, second panel, auto-unfold logic
└── index.css # MODIFY: new transition + vertical text utilities
```
**Structure Decision**: All changes fit within the existing component structure. No new files or directories needed. The StatBlockPanel component is reused for both browse and pinned panels via a prop.
## Design Decisions
### D-001: Panel Component Reuse
The same `StatBlockPanel` component renders both the browse (right) and pinned (left) panels. A new `role` prop (`"browse" | "pinned"`) controls:
- **browse**: Shows fold button + pin button (xl+ only). Positioned right-0.
- **pinned**: Shows unpin button only. Positioned left-0. No fold behavior (always expanded when present).
This avoids duplicating the panel chrome, scrolling, stat block rendering, and bestiary fetch logic.
### D-002: Fold State Architecture
`isRightPanelFolded` is a boolean state in App.tsx. When true:
- The browse panel renders as a slim folded tab (40px wide, right edge)
- The creature name is displayed vertically using `writing-mode: vertical-rl`
- Clicking the tab sets `isRightPanelFolded = false`
The fold state is independent of `selectedCreatureId` — folding preserves the selection, unfolding restores it. Setting `selectedCreatureId` to null still fully removes the panel.
### D-003: Animation Strategy
Use CSS `transition: translate 200ms ease-out` on the panel container. Toggle between `translate-x-0` (expanded) and `translate-x-[calc(100%-40px)]` (folded, with tab visible). This gives smooth bidirectional animation with a single CSS rule, matching the existing `slide-in-right` pattern.
The folded tab is part of the panel element itself (rendered at the left edge of the 400px panel), so when the panel translates right, the tab remains visible at the viewport edge.
### D-004: Fold State Respected on Turn Change
The existing auto-show logic sets `selectedCreatureId` when the active combatant changes, but does NOT change `isRightPanelFolded`. If the user folded the panel, advancing turns updates which creature is selected internally — unfolding later shows the current active creature. This respects the user's deliberate fold action.
### D-005: Viewport-Responsive Pin Button
The pin button visibility requires: (1) wide desktop viewport (`min-width: 1280px` / xl breakpoint), AND (2) a resolved creature is displayed (hidden during source fetch prompts and bulk import mode). A new `isWideDesktop` state (1280px) controls the viewport condition. Same `matchMedia` listener pattern as existing code.
### D-006: Mobile Behavior
On mobile (< 1024px), the drawer/backdrop pattern is preserved. The close button (X) is replaced with a fold icon, but tapping the backdrop still fully dismisses the panel (sets `selectedCreatureId = null`). The fold toggle on mobile slides the drawer off-screen with the same animation. No pin button on mobile.
## Component Props Changes
### StatBlockPanel — New Props
```
panelRole: "browse" | "pinned" — controls header buttons and positioning (named panelRole to avoid ARIA role conflict)
isFolded: boolean — whether panel is in folded tab state (browse only)
onToggleFold: () => void — callback to toggle fold state (browse only)
onPin: () => void — callback to pin current creature (browse only, xl+)
onUnpin: () => void — callback to unpin (pinned only, rendered top-right for consistency)
showPinButton: boolean — whether pin button is visible (viewport + creature loaded)
side: "left" | "right" — controls fixed positioning (left-0 vs right-0)
onDismiss: () => void — callback for mobile backdrop dismiss
```
### StatBlockPanel — Removed Props
```
onClose: () => void — replaced by fold/unfold; backdrop dismiss handled internally
```
Note: On mobile, backdrop click still needs to fully dismiss. This will be handled by the parent passing an `onDismiss` callback or by the fold action on mobile mapping to dismiss behavior.
## Complexity Tracking
No constitution violations. No complexity justifications needed.

View File

@@ -0,0 +1,41 @@
# Quickstart: Stat Block Panel Fold/Unfold and Pin
**Feature**: 035-statblock-fold-pin
**Date**: 2026-03-11
## What This Feature Does
Replaces the stat block panel's close button with fold/unfold controls and adds the ability to pin a creature to a second panel for side-by-side reference.
## Key Files to Modify
| File | Change |
|------|--------|
| `apps/web/src/components/stat-block-panel.tsx` | Core changes: remove close button + heading, add fold/unfold toggle, add pin/unpin buttons, add folded tab view, accept new props for panel role |
| `apps/web/src/App.tsx` | New state: `pinnedCreatureId`, `isRightPanelFolded`. Render second StatBlockPanel for pinned creature. Update auto-show logic to unfold panel. |
| `apps/web/src/index.css` | New CSS: folded tab styles, slide transition utility, vertical text utility |
## New Files
None expected. All changes fit within existing component structure.
## Architecture Notes
- **Layer**: All changes are in the Adapter (web) layer — no domain or application changes needed.
- **State**: Ephemeral React state only — no persistence changes.
- **Component reuse**: The same `StatBlockPanel` component serves both browse (right) and pinned (left) panels, differentiated by a prop.
- **Animation**: CSS transitions on `translate` property, matching existing 200ms ease-out pattern.
## Dev Commands
```bash
pnpm --filter web dev # Start dev server to test visually
pnpm vitest run apps/web/src # Run web app tests
pnpm check # Full quality gate before commit
```
## Testing Strategy
- Unit tests for fold/unfold state transitions and conditional rendering
- Unit tests for pin/unpin behavior and viewport-responsive pin button visibility
- Visual testing via dev server at various viewport widths (< 1024px, 1024-1279px, 1280px+)

View File

@@ -0,0 +1,66 @@
# Research: Stat Block Panel Fold/Unfold and Pin
**Feature**: 035-statblock-fold-pin
**Date**: 2026-03-11
## R-001: Fold/Unfold Animation Approach
**Decision**: Use CSS `translate` with `transition` property for fold/unfold, matching the existing `slide-in-right` keyframes pattern.
**Rationale**: The project already uses `translate: 100%``translate: 0` for the mobile drawer animation (index.css lines 58-69). The fold/unfold is the same motion in reverse. Using CSS transitions (rather than keyframes) gives us bidirectional animation without separate fold/unfold keyframes — the browser interpolates both directions from a single `transition: translate 200ms ease-out` declaration.
**Alternatives considered**:
- Keyframe animations (`animate-slide-out-right` + `animate-slide-in-right`): More verbose, requires managing animation-fill-mode and class toggling. Rejected — transitions are simpler for toggle states.
- JS-driven animation (Web Animations API): Overkill for a simple translate. Rejected.
## R-002: Folded Tab Design
**Decision**: The folded tab is a narrow strip (~40px wide) fixed to the right edge, containing the creature name rendered vertically using `writing-mode: vertical-rl`. The tab acts as a clickable button to unfold.
**Rationale**: `writing-mode: vertical-rl` is well-supported (96%+ browser support) and produces clean vertical text without transform hacks. The 40px width accommodates typical creature names at ~14px font size. The tab is part of the same component — it renders conditionally based on fold state, not as a separate element.
**Alternatives considered**:
- `transform: rotate(-90deg)`: Causes layout issues (element still occupies horizontal space). Rejected.
- Truncated horizontal text in narrow strip: Unreadable at 40px. Rejected.
- Icon-only tab (no creature name): Loses context about which creature is loaded. Rejected — spec requires creature name.
## R-003: Dual-Panel Breakpoint
**Decision**: Use `1280px` (Tailwind's `xl` breakpoint) as the minimum viewport width for showing the pin button and supporting dual panels.
**Rationale**: Two 400px panels + the centered content container (max 672px = max-w-2xl) totals 1472px. At 1280px, the content column compresses slightly but remains usable. Below 1280px, single-panel mode is sufficient. The existing desktop breakpoint is 1024px (lg) for single panel; xl provides a clean step-up for dual panels.
**Alternatives considered**:
- 1024px (same as single panel): Not enough room for two 400px panels + content. Rejected.
- 1440px: Too restrictive — excludes many laptop screens. Rejected.
- Custom breakpoint (e.g., 1200px): Deviates from Tailwind defaults without strong reason. Rejected.
## R-004: Pinned Panel State Management
**Decision**: Lift pinned creature state (`pinnedCreatureId: CreatureId | null`) to App.tsx alongside `selectedCreatureId`. The pinned panel reuses the same `StatBlockPanel` component with a `variant` or `role` prop to control header buttons (unpin vs fold/pin).
**Rationale**: App.tsx already manages `selectedCreatureId` and passes creature data down. Adding `pinnedCreatureId` follows the same pattern. The StatBlockPanel component handles its own desktop/mobile rendering, so reusing it for the pinned panel avoids duplication. The pinned panel only renders on desktop at xl+ breakpoints.
**Alternatives considered**:
- Separate PinnedPanel component: Creates duplication of panel chrome, scrolling, and stat block rendering. Rejected — use props to differentiate.
- Context/store for panel state: Over-engineering for two pieces of state. Rejected — simple useState is sufficient.
## R-005: Mobile Behavior Preservation
**Decision**: On mobile (< 1024px), keep the existing drawer/backdrop pattern. Replace the X close button with a fold toggle that slides the drawer off-screen. The backdrop click still dismisses (sets selectedCreatureId to null). No pin button on mobile.
**Rationale**: The spec requires mobile behavior preservation (FR-010). The fold toggle on mobile provides consistency with desktop while the backdrop click provides the familiar dismiss pattern. Pinning doesn't make sense on small screens where two panels can't coexist.
**Alternatives considered**:
- Keep X close button on mobile, fold only on desktop: Inconsistent UX across breakpoints. Rejected — spec says replace close button.
- Add close button alongside fold on mobile: Spec explicitly removes close button (FR-001). Rejected.
## R-006: Panel Layout Architecture
**Decision**: Both panels use `position: fixed` anchored to their respective edges. The pinned panel uses `left-0` while the browse panel uses `right-0`. The main content area does not reflow — panels overlay the edges of the viewport.
**Rationale**: The current panel already uses fixed positioning (right-0). Adding a left-0 panel follows the same pattern. Fixed positioning avoids complex flex/grid layout changes to App.tsx. The main content is already centered with `max-w-2xl mx-auto`, so panels on either side naturally overlay the unused viewport margins on wide screens.
**Alternatives considered**:
- CSS Grid with named areas: Requires restructuring App.tsx layout. Over-engineering for panels that already work as fixed overlays. Rejected.
- Flex layout with panel slots: Same restructuring concern. Rejected.

View File

@@ -0,0 +1,102 @@
# Feature Specification: Stat Block Panel Fold/Unfold and Pin
**Feature Branch**: `035-statblock-fold-pin`
**Created**: 2026-03-11
**Status**: Draft
**Input**: User description: "Stat block panel: fold/unfold and pin to second panel"
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Fold and Unfold Stat Block Panel (Priority: P1)
As a DM running an encounter, I want to collapse the stat block panel to a slim tab so I can temporarily reclaim screen space without losing my place, then quickly expand it again to reference creature stats.
**Why this priority**: The fold/unfold mechanism replaces the existing close button and is the core interaction model for the redesigned panel. Without it, users have no way to dismiss or restore the panel.
**Independent Test**: Can be fully tested by opening a stat block, folding it, verifying the tab appears with the creature name, then unfolding it and verifying the full panel returns with the same creature displayed.
**Acceptance Scenarios**:
1. **Given** the stat block panel is open showing a creature, **When** the user clicks the fold button, **Then** the panel slides out to the right edge and a slim vertical tab appears showing the creature's name.
2. **Given** the stat block panel is folded to a tab, **When** the user clicks the tab, **Then** the panel slides back in showing the same creature that was displayed before folding.
3. **Given** the stat block panel is open, **When** the user looks for a close button, **Then** no close button is present — only a fold toggle.
4. **Given** the stat block panel is open, **When** the user looks at the panel header, **Then** no "Stat Block" heading text is visible.
5. **Given** the panel is folding or unfolding, **When** the animation plays, **Then** it completes with a smooth slide transition (~200ms ease-out).
---
### User Story 2 - Pin Creature to Second Panel (Priority: P2)
As a DM comparing creatures or referencing one creature while browsing others, I want to pin the current creature to a secondary panel on the left side of the screen so I can keep it visible while browsing different creatures in the right panel.
**Why this priority**: Pinning enables a dual-panel workflow that significantly enhances usability for creature comparison and mid-combat reference. It depends on the fold/unfold panel being functional first.
**Independent Test**: Can be fully tested by opening a stat block, clicking the pin button, verifying a second panel appears on the left with the same creature, then selecting a different creature and verifying it appears in the right panel while the pinned creature remains on the left.
**Acceptance Scenarios**:
1. **Given** the stat block panel is showing a creature on a wide screen, **When** the user clicks the pin button, **Then** the current creature appears in a new panel on the left side of the screen.
2. **Given** a creature is pinned to the left panel, **When** the user clicks a different combatant in the encounter list, **Then** the right panel updates to show the new creature while the left panel continues showing the pinned creature.
3. **Given** a creature is pinned to the left panel, **When** the user clicks the unpin button on the left panel, **Then** the left panel is removed and only the right panel remains.
4. **Given** the user is on a small screen or mobile viewport, **When** the stat block panel is displayed, **Then** the pin button is not visible.
---
### User Story 3 - Fold Behavior with Pinned Panel (Priority: P3)
As a DM with a creature pinned, I want to fold the right (browse) panel independently so I can focus on just the pinned creature when needed, or fold both panels to see the full encounter list.
**Why this priority**: This is an interaction refinement for the dual-panel state that builds on both P1 and P2 stories.
**Independent Test**: Can be tested by pinning a creature, then folding the right panel and verifying the pinned panel remains visible, and vice versa.
**Acceptance Scenarios**:
1. **Given** both pinned (left) and browse (right) panels are open, **When** the user folds the right panel, **Then** the left pinned panel remains visible and the right panel collapses to a tab.
2. **Given** the right panel is folded and the left panel is pinned, **When** the user unfolds the right panel, **Then** it slides back showing the last browsed creature.
---
### Edge Cases
- What happens when the user pins a creature and then that creature is removed from the encounter? The pinned panel continues displaying the creature's stat block (data is already loaded).
- What happens when the user folds the panel and then the active combatant changes (auto-show logic on desktop)? The panel stays folded but updates the selected creature internally, so unfolding shows the current active combatant's stat block. The fold state is respected — the user chose to fold, so advancing turns should not override that.
- What happens when the viewport is resized from wide to narrow while a creature is pinned? The pinned (left) panel is hidden and the pin button disappears; if resized back to wide, the pinned panel reappears.
- What happens when the user is in bulk import mode and tries to fold? The fold/unfold behavior applies to the bulk import panel identically.
- What happens when the panel is showing a source fetch prompt (creature not yet loaded)? The pin button is hidden — pinning only makes sense when a creature is actually displayed.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST replace the close button on the stat block panel with a fold/unfold toggle control.
- **FR-002**: System MUST remove the "Stat Block" heading text from the panel header.
- **FR-003**: When folded, the panel MUST collapse to a slim vertical tab anchored to the right edge of the screen displaying the creature's name.
- **FR-004**: Folding and unfolding MUST use a smooth CSS slide animation (~200ms ease-out).
- **FR-005**: The fold/unfold toggle MUST preserve the currently displayed creature — unfolding shows the same creature that was visible when folded.
- **FR-006**: The panel MUST include a pin button that copies the current creature to a new panel on the left side of the screen.
- **FR-007**: After pinning, the right panel MUST remain active for browsing different creatures independently.
- **FR-008**: The pinned (left) panel MUST include an unpin button that removes it when clicked.
- **FR-009**: The pin button MUST be hidden on viewports where dual panels would not fit (small screens / mobile).
- **FR-010**: On mobile viewports, the existing drawer/backdrop behavior MUST be preserved — fold/unfold replaces only the close button behavior for the desktop layout.
### Key Entities
- **Panel State**: Represents whether a stat block panel is expanded, folded, or absent. Each panel (browse and pinned) has independent state.
- **Pinned Creature**: The creature whose stat block is locked into the left panel, independent of the browse panel's current selection.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Users can fold the stat block panel in a single click and unfold it in a single click, with the transition completing in under 300ms.
- **SC-002**: Users can pin a creature and browse a different creature's stat block simultaneously, without any additional navigation steps.
- **SC-003**: The pinned panel is never visible on screens narrower than the dual-panel breakpoint, ensuring mobile usability is not degraded.
- **SC-004**: All fold/unfold and pin/unpin interactions are discoverable through visible button controls without requiring documentation or tooltips.
## Assumptions
- The dual-panel breakpoint will be determined by the existing desktop breakpoint (currently 1024px) plus the width needed for two panels. A reasonable default is ~1280px or wider.
- The slim folded tab will be oriented vertically with the creature name rotated or truncated to fit a narrow strip (~32-40px wide).
- The pinned panel will have the same width and styling as the browse panel but positioned on the left edge.
- On mobile, the existing slide-in drawer with backdrop behavior is preserved; the fold/unfold toggle replaces the close button but the backdrop click still dismisses the panel.

View File

@@ -0,0 +1,191 @@
# Tasks: Stat Block Panel Fold/Unfold and Pin
**Input**: Design documents from `/specs/035-statblock-fold-pin/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md
**Tests**: Acceptance scenario tests in Phase 7 (`apps/web/src/__tests__/stat-block-fold-pin.test.tsx`).
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: CSS utilities needed by all user stories
- [x] T001 Add CSS transition utility for panel slide animation (`transition: translate 200ms ease-out`) in `apps/web/src/index.css`
- [x] T002 [P] Add CSS utility for vertical text (`writing-mode: vertical-rl`) in `apps/web/src/index.css`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Refactor StatBlockPanel props interface to support role-based rendering before any user story work
**CRITICAL**: No user story work can begin until this phase is complete
- [x] T003 Update `StatBlockPanelProps` interface in `apps/web/src/components/stat-block-panel.tsx`: add `panelRole` (`"browse" | "pinned"`), `isFolded` (boolean), `onToggleFold` (callback), `onPin` (callback), `onUnpin` (callback), `showPinButton` (boolean), `side` (`"left" | "right"`), `onDismiss` (callback for mobile backdrop). Remove `onClose` prop.
- [x] T004 Update `App.tsx` to pass the new props to StatBlockPanel: set `panelRole="browse"`, `side="right"`, wire `onDismiss` to clear `selectedCreatureId`, add `isRightPanelFolded` state (default `false`), wire `onToggleFold`. File: `apps/web/src/App.tsx`
**Checkpoint**: App compiles and existing panel behavior works with new prop interface (fold/pin not yet functional)
---
## Phase 3: User Story 1 - Fold and Unfold Stat Block Panel (Priority: P1) MVP
**Goal**: Replace close button with fold/unfold toggle; panel collapses to slim right-edge tab with creature name and smooth animation
**Independent Test**: Open a stat block, fold it, verify slim tab with creature name appears on right edge, unfold it, verify full panel returns with same creature
### Implementation for User Story 1
- [x] T005 [US1] Remove "Stat Block" heading text (`panelTitle` / `<span>` element) from both desktop and mobile header sections in `apps/web/src/components/stat-block-panel.tsx`
- [x] T006 [US1] Replace the close button (X icon) with a fold toggle button (use `PanelRightClose` / `PanelRightOpen` Lucide icons or similar chevron) in the desktop header of `apps/web/src/components/stat-block-panel.tsx`. Wire to `onToggleFold` prop.
- [x] T007 [US1] Implement the folded tab view in the desktop branch of `apps/web/src/components/stat-block-panel.tsx`: when `isFolded` is true, render a 40px-wide clickable strip anchored to the right edge showing the creature name vertically (using the vertical text CSS utility from T002). Clicking the tab calls `onToggleFold`.
- [x] T008 [US1] Add the CSS slide transition to the desktop panel container in `apps/web/src/components/stat-block-panel.tsx`: apply the transition utility (from T001), toggle between `translate-x-0` (expanded) and `translate-x-[calc(100%-40px)]` (folded) based on `isFolded` prop. The folded tab must remain visible at the viewport edge.
- [x] T009 [US1] Update mobile drawer behavior in `apps/web/src/components/stat-block-panel.tsx`: replace X close button with fold icon matching desktop. Backdrop click calls `onDismiss` (full dismiss). Fold toggle on mobile also calls `onDismiss` (since mobile has no folded tab state — fold = dismiss on mobile).
- [x] T010 [US1] Update auto-show logic in `apps/web/src/App.tsx`: when active combatant changes and `selectedCreatureId` is set, also set `isRightPanelFolded = false` to auto-unfold the panel on turn change.
**Checkpoint**: Panel folds to slim tab on desktop, unfolds on click, no close button, no heading, mobile drawer preserved with fold-as-dismiss, auto-unfold on turn change works
---
## Phase 4: User Story 2 - Pin Creature to Second Panel (Priority: P2)
**Goal**: Pin button copies current creature to a left-side panel; right panel stays active for browsing; pin hidden on small screens
**Independent Test**: Open a stat block on xl+ viewport, click pin, verify left panel appears with same creature, select different combatant, verify right panel updates while left stays, click unpin, verify left panel removed
### Implementation for User Story 2
- [x] T011 [US2] Add `pinnedCreatureId` state and derived `pinnedCreature` (via `getCreature`) in `apps/web/src/App.tsx`. Add `isWideDesktop` state using `matchMedia("(min-width: 1280px)")` with change listener (same pattern as existing `isDesktop` in stat-block-panel.tsx).
- [x] T012 [US2] Wire pin/unpin callbacks in `apps/web/src/App.tsx`: `onPin` sets `pinnedCreatureId = selectedCreatureId`, `onUnpin` sets `pinnedCreatureId = null`. Pass `showPinButton = isWideDesktop` to browse panel.
- [x] T013 [US2] Render the pinned StatBlockPanel in `apps/web/src/App.tsx`: conditionally render a second `<StatBlockPanel>` when `pinnedCreatureId` is set and `isWideDesktop` is true, with `panelRole="pinned"`, `side="left"`, creature data from `pinnedCreature`, and `onUnpin` callback.
- [x] T014 [US2] Implement pin and unpin button rendering in `apps/web/src/components/stat-block-panel.tsx`: when `panelRole="browse"` and `showPinButton` is true, render a pin icon button (use `Pin` Lucide icon) in the header that calls `onPin`. When `panelRole="pinned"`, render an unpin icon button (use `PinOff` Lucide icon) in the header that calls `onUnpin`.
- [x] T015 [US2] Update panel positioning in `apps/web/src/components/stat-block-panel.tsx`: use the `side` prop to apply `left-0 border-r` for pinned panel vs `right-0 border-l` for browse panel in the desktop layout classes.
**Checkpoint**: Pin button visible on xl+ screens, clicking it creates left panel with same creature, right panel browses independently, unpin removes left panel, pin button hidden below 1280px
---
## Phase 5: User Story 3 - Fold Behavior with Pinned Panel (Priority: P3)
**Goal**: Right panel folds independently while pinned panel remains visible; unfolding restores last browsed creature
**Independent Test**: Pin a creature, fold right panel, verify pinned panel stays visible and right panel shows folded tab, unfold right panel, verify it shows last browsed creature
### Implementation for User Story 3
- [x] T016 [US3] Verify fold/unfold independence in `apps/web/src/App.tsx`: ensure `isRightPanelFolded` state only affects the browse panel and does not hide or modify the pinned panel. The pinned panel has no fold state — it is always expanded when present.
- [x] T017 [US3] Verify fold preserves `selectedCreatureId` in `apps/web/src/App.tsx`: when `isRightPanelFolded` is toggled to true, `selectedCreatureId` must remain unchanged so unfolding restores the same creature.
**Checkpoint**: Both panels operate independently — fold right panel while pinned panel stays, unfold shows last creature
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Edge cases, cleanup, and quality gate
- [x] T018 Handle viewport resize edge case in `apps/web/src/App.tsx`: when `isWideDesktop` changes from true to false and `pinnedCreatureId` is set, the pinned panel should stop rendering (handled by conditional render). When resized back to wide, pinned panel reappears with same creature.
- [x] T019 Verify bulk import mode works with fold/unfold in `apps/web/src/components/stat-block-panel.tsx`: `bulkImportMode` panels use the same fold toggle behavior. Folded tab shows "Bulk Import" as creature name fallback.
- [x] T020 Run `pnpm check` (full quality gate: audit + knip + biome + typecheck + test/coverage + jscpd) and fix any issues
- [x] T021 Verify pinned creature removal edge case in `apps/web/src/App.tsx`: `pinnedCreatureId` is not cleared on combatant removal — data resolved via `getCreature` independently.
---
## Phase 7: Acceptance Tests
**Purpose**: Map spec acceptance scenarios to automated tests
- [x] T022 [P] [US1] Test fold/unfold behavior in `apps/web/src/__tests__/stat-block-fold-pin.test.tsx`: 7 tests covering fold button, no close button, no heading, folded tab with creature name, toggle callbacks, translate classes.
- [x] T023 [P] [US1] Test mobile behavior in `apps/web/src/__tests__/stat-block-fold-pin.test.tsx`: 3 tests covering fold button on mobile, backdrop dismiss, pinned panel not rendered on mobile.
- [x] T024 [P] [US2] Test pin/unpin behavior in `apps/web/src/__tests__/stat-block-fold-pin.test.tsx`: 7 tests covering pin/unpin buttons, callbacks, panel positioning (left/right).
- [x] T025 [P] [US3] Test fold independence with pinned panel in `apps/web/src/__tests__/stat-block-fold-pin.test.tsx`: 2 tests verifying pinned panel has no fold button and is always expanded.
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — can start immediately
- **Foundational (Phase 2)**: Depends on Phase 1 (CSS utilities) — BLOCKS all user stories
- **User Story 1 (Phase 3)**: Depends on Phase 2 completion
- **User Story 2 (Phase 4)**: Depends on Phase 2 completion. Can run in parallel with US1 but integrates more cleanly after US1.
- **User Story 3 (Phase 5)**: Depends on US1 and US2 being complete (verifies their interaction)
- **Polish (Phase 6)**: Depends on all user stories being complete
- **Acceptance Tests (Phase 7)**: Depends on all user stories being complete. All test tasks [P] can run in parallel.
### User Story Dependencies
- **User Story 1 (P1)**: Can start after Foundational (Phase 2) — no dependencies on other stories
- **User Story 2 (P2)**: Can start after Foundational (Phase 2) — recommended after US1 for cleaner integration
- **User Story 3 (P3)**: Depends on US1 + US2 (verifies combined behavior)
### Within Each User Story
- Props/state wiring before UI rendering
- CSS utilities before components that use them
- Core implementation before edge cases
### Parallel Opportunities
- T001 and T002 can run in parallel (different CSS utilities, same file but independent additions)
- T005 and T006 modify different sections of stat-block-panel.tsx header — can be done together
- T011 and T014 modify different files (App.tsx vs stat-block-panel.tsx) — can run in parallel
---
## Parallel Example: User Story 1
```bash
# T005 + T006 can be done in one pass (both modify header in stat-block-panel.tsx):
Task: "Remove Stat Block heading and replace close with fold toggle in apps/web/src/components/stat-block-panel.tsx"
# T007 + T008 are sequential (T007 creates the tab, T008 adds animation to it)
```
## Parallel Example: User Story 2
```bash
# These modify different files and can run in parallel:
Task: "T011 — Add pinnedCreatureId state + isWideDesktop in apps/web/src/App.tsx"
Task: "T014 — Add pin/unpin buttons in apps/web/src/components/stat-block-panel.tsx"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: CSS utilities (T001-T002)
2. Complete Phase 2: Props refactor (T003-T004)
3. Complete Phase 3: Fold/unfold (T005-T010)
4. **STOP and VALIDATE**: Test fold/unfold independently on desktop and mobile
5. Deploy/demo if ready — panel folds and unfolds, no close button, no heading
### Incremental Delivery
1. Setup + Foundational → App compiles with new prop interface
2. Add User Story 1 → Test fold/unfold → Deploy/Demo (MVP!)
3. Add User Story 2 → Test pin/unpin → Deploy/Demo
4. Add User Story 3 → Verify combined behavior → Deploy/Demo
5. Polish → Run quality gate
6. Acceptance Tests → Verify all scenarios → Commit
---
## Notes
- [P] tasks = different files, no dependencies
- [Story] label maps task to specific user story for traceability
- Each user story should be independently completable and testable
- Commit after each phase or logical group of tasks
- Stop at any checkpoint to validate story independently
- US3 is lightweight verification — most behavior should already work from US1 + US2 wiring