# 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.