# Story 3.1: Save & Load Scene Presets **Status:** ready-for-dev **Epic:** 3 - Scene-Aware Camera Automation (Scene Presets) **Story Key:** 3-1-save-and-load-scene-presets **Created:** 2026-05-23 **Last Updated:** 2026-05-23 --- ## Story Header | Field | Value | |-------|-------| | **Epic** | 3 - Scene-Aware Camera Automation (Scene Presets) | | **Story ID** | 3.1 | | **Story Key** | 3-1-save-and-load-scene-presets | | **Title** | Save & Load Scene Presets | | **Status** | ready-for-dev | | **Priority** | High (first story in Epic 3) | | **Assigned Agent** | DEV (Amelia) | | **Created** | 2026-05-23 | --- ## πŸ“‹ Story Requirements ### User Story **As a** GM, **I want to** save the current camera layout as a named preset and load it at any time, **So that** I can instantly reproduce proven camera arrangements without reconfiguring them from scratch. ### Persona Alignment - **Primary:** Marcus (Veteran GM) - Preps meticulously, wants zero friction during play, needs camera automation he can configure once - **Secondary:** Jake (Streamer) - Needs complete independence between scenes for broadcast production ### Acceptance Criteria (BDD Format) #### AC-1: Save Preset via Director's Board **Given** the Director's Board is open **When** the GM clicks "Save Preset…" in the board footer **Then** a prompt appears for a preset name **And** on confirmation, the current Visibility Matrix is captured and stored on the current Scene document flag `{ _version: 1, presets: {...} }` #### AC-2: Duplicate Name Handling **Given** a preset name already exists **When** the GM saves with the same name **Then** the GM is asked to confirm overwrite before the preset is replaced #### AC-3: Maximum Presets Limit **Given** the world already has 50 presets **When** the GM attempts to save a 51st **Then** an error message shows: "Maximum of 50 presets reached. Delete an existing preset to save a new one." #### AC-4: Load Preset via Director's Board **Given** saved presets exist **When** the GM clicks "Load Preset…" in the Director's Board footer **Then** a list of available presets is shown **And** selecting one overwrites the current Visibility Matrix and broadcasts to all clients within 500ms #### AC-5: Notification on Preset Load **Given** a preset is loaded **When** all clients receive the broadcast **Then** a notification fires: "GM applied preset: [Preset Name]" via `ui.notifications` #### AC-6: Offline Participant State Persistence **Given** a participant is offline when a preset is loaded **When** they reconnect **Then** they receive the state from the loaded preset (not the previous live state) #### AC-7: Rename Preset Validation **Given** the GM renames a preset **When** the new name conflicts with an existing preset **Then** an error is shown and the rename is rejected ### Functional Requirements Covered - **FR-15:** GM saves a named Scene Preset from the current Visibility Matrix - **FR-16:** GM loads a Scene Preset at any time, overriding the current Visibility Matrix ### Success Criteria - [ ] All 7 acceptance criteria pass manual testing - [ ] All unit tests pass (target: +40-50 new tests) - [ ] `npm run lint` exits 0 - [ ] `npm run typecheck` exits 0 - [ ] Code review passes with no critical findings --- ## 🎯 Developer Context Section ### Epic Context **Epic 3: Scene-Aware Camera Automation (Scene Presets)** builds on the foundation established in Epics 1 and 2: - **Epic 1** delivered core visibility control (right-click toggle, state persistence, AV tile indicators) - **Epic 2** delivered player notifications and the Director's Board (bulk actions, keyboard shortcuts) - **Epic 3** extends this with **scene-aware automation** - presets that can be saved, loaded manually, and eventually auto-applied on scene transitions This epic represents **Level 3** of the Progressive Enhancement Architecture, moving from manual control to automation. ### Cross-Epic Dependencies | Dependency | Source Epic | Status | |------------|-------------|--------| | Visibility Matrix persistence | Epic 1 (Story 1.3) | βœ… Complete | | StateStore.setMatrix() method | Epic 1 (Story 1.4) | βœ… Available | | Director's Board UI | Epic 2 (Story 2.2) | βœ… Complete | | Bulk operation patterns | Epic 2 (Story 2.3) | βœ… Available | | NotificationBus | Epic 2 (Story 2.1) | βœ… Available | | Socket broadcast infrastructure | Epic 1 (Story 1.3) | βœ… Available | ### Story Sequence in Epic 3 ``` Story 3.1 (Save & Load) β†’ Story 3.2 (Auto-Apply & ConfirmationBar) β†’ Story 3.3 (Import/Export) ``` **This is Story 3.1** - the foundation for the entire Epic 3 feature set. Stories 3.2 and 3.3 build directly on the ScenePresetManager created here. ### Business Value - **For Marcus (Veteran GM):** Pre-configure camera layouts during prep, apply instantly during sessions without disrupting flow - **For Jake (Streamer):** Professional scene transitions with consistent camera states for broadcast - **For All GMs:** Reduces cognitive load during sessions by automating repetitive setup --- ## πŸ—οΈ Technical Requirements ### Core Components to Create | Component | File | Purpose | |-----------|------|---------| | ScenePresetManager | `src/core/ScenePresetManager.js` | Manages preset CRUD, Scene flag storage | | PresetSaveDialog | `src/ui/gm/PresetSaveDialog.js` | Modal for preset name entry | | PresetLoadDialog | `src/ui/gm/PresetLoadDialog.js` | Modal for preset selection | | PresetManager UI | Extend DirectorsBoard footer | Save/Load buttons and preset list | ### Data Flow ``` GM Action (Save) β†’ ScenePresetManager.save() β†’ Scene.setFlag() β†’ Confirmation ↓ GM Action (Load) β†’ ScenePresetManager.load() β†’ StateStore.setMatrix() β†’ Socket broadcast ↓ β†’ NotificationBus β†’ ui.notifications ``` ### Persistence Strategy **Storage Location:** Scene document flags (not world settings) - **Key:** `scene.getFlag('video-view-manager', 'presets')` - **Schema:** `{ _version: 1, presets: { [name: string]: ScenePreset } }` - **Rationale:** Presets are Scene-specific; different scenes need different camera layouts **ScenePreset Structure:** ```javascript { _version: 1, name: "string (unique)", matrix: { userId: VisibilityState, ... }, createdAt: number (timestamp), updatedAt: number (timestamp) } ``` ### Socket Messages (New Events) Based on existing contract in `src/contracts/socket-message.js`: - `PRESET_APPLY: "scrying-pool.preset.apply"` - GM requests preset apply - `PRESET_APPLIED: "scrying-pool.preset.applied"` - Authoritative confirmation **Note:** These events are already defined in the contract but not yet implemented in SocketHandler. --- ## πŸ›οΈ Architecture Compliance ### Import Boundary Rules (HARD - ESLint Enforced) ``` src/core/ScenePresetManager.js β†’ may import: src/contracts/, src/utils/ ONLY src/ui/gm/PresetSaveDialog.js β†’ may import: src/core/, src/contracts/, src/utils/ ONLY src/ui/gm/PresetLoadDialog.js β†’ may import: src/core/, src/contracts/, src/utils/ ONLY ``` **❌ FORBIDDEN:** - No imports from `src/foundry/` inside `src/core/` or `src/ui/` - No direct `game.*` access in testable modules - No circular dependencies ### Constructor Pattern (Side-Effect-Free) All new classes MUST follow the established pattern: ```javascript export class ScenePresetManager { constructor(adapter, stateStore, socketHandler) { // Store dependencies, NO side effects this._adapter = adapter; this._stateStore = stateStore; this._socketHandler = socketHandler; // Initialize internal state this._presets = new Map(); } init() { // Register hooks, load initial state } teardown() { // Clean up hooks, timers, listeners } } ``` ### Dependency Injection Contract **Required Injections for ScenePresetManager:** ```javascript { scenes: { current: () => Scene|null, getFlag: (sceneId, key) => any, setFlag: (sceneId, key, value) => Promise }, users: { isGM: () => boolean, current: () => User|null, all: () => User[] }, hooks: { on: (event, handler) => number, off: (event, handler) => void, callAll: (event, data) => void } } ``` ### State Authority - **ScenePresetManager** owns preset CRUD operations - **StateStore** owns Visibility Matrix state (ScenePresetManager calls StateStore.setMatrix()) - **SocketHandler** owns socket broadcast (ScenePresetManager calls SocketHandler.emit()) - **ScryingPoolController** owns revision tracking and PendingOp lifecycle --- ## πŸ“¦ Library & Framework Requirements ### Existing Dependencies (Already Available) | Library | Version | Purpose | |---------|---------|---------| | FoundryVTT v14 API | N/A | Native module API | | Font Awesome 6 | Bundled | Icons | | Handlebars | Bundled | Templates | | LESS 4.6.4 | Bundled | CSS preprocessing | ### New Dependencies **None** - This story uses only existing FoundryVTT v14 APIs and the module's existing infrastructure. ### Contract Files (Already Exist) - βœ… `src/contracts/scene-preset.js` - Already implemented with createScenePreset(), isValidScenePreset() - βœ… `src/contracts/socket-message.js` - Already defines PRESET_APPLY and PRESET_APPLIED events - βœ… `src/contracts/visibility-matrix.js` - Already defines matrix validation --- ## πŸ“ File Structure Requirements ### New Files to Create ``` πŸ“ src/core/ └── ScenePresetManager.js # NEW - Core preset management πŸ“ src/ui/gm/ β”œβ”€β”€ PresetSaveDialog.js # NEW - Save preset modal └── PresetLoadDialog.js # NEW - Load preset modal πŸ“ tests/unit/core/ └── ScenePresetManager.test.js # NEW - Unit tests πŸ“ tests/unit/ui/gm/ β”œβ”€β”€ PresetSaveDialog.test.js # NEW - Unit tests └── PresetLoadDialog.test.js # NEW - Unit tests πŸ“ templates/ β”œβ”€β”€ preset-save-dialog.hbs # NEW - Handlebars template └── preset-load-dialog.hbs # NEW - Handlebars template πŸ“ styles/components/ β”œβ”€β”€ _preset-save-dialog.less # NEW - Dialog styles └── _preset-load-dialog.less # NEW - Dialog styles ``` ### Modified Files ``` πŸ“„ module.js # Add ScenePresetManager wiring πŸ“„ src/ui/gm/DirectorsBoard.js # Add Save/Load buttons to footer πŸ“„ templates/directors-board.hbs # Enable Save/Load buttons πŸ“„ lang/en.json # Add i18n keys for preset UI πŸ“„ styles/components/_directors-board.less # Style footer buttons ``` ### File Ownership & Responsibilities | File | Owns | Collaborates With | |------|------|------------------| | ScenePresetManager.js | Preset CRUD, Scene flag storage | StateStore, SocketHandler, FoundryAdapter | | PresetSaveDialog.js | Name input, validation, confirmation | ScenePresetManager | | PresetLoadDialog.js | Preset listing, selection, load confirmation | ScenePresetManager | | DirectorsBoard.js | UI integration, button wiring | ScenePresetManager | --- ## πŸ§ͺ Testing Requirements ### Unit Test Coverage Targets | Module | Test File | Target Tests | Coverage Focus | |--------|-----------|---------------|----------------| | ScenePresetManager | ScenePresetManager.test.js | 30-40 | CRUD, validation, edge cases | | PresetSaveDialog | PresetSaveDialog.test.js | 15-20 | Input validation, confirmation, cancel | | PresetLoadDialog | PresetLoadDialog.test.js | 15-20 | Listing, selection, load, cancel | | **Total** | | **60-80** | All public methods, edge cases | ### Test Patterns to Follow **From Story 2.1 (NotificationBus):** ```javascript // Use vi.useFakeTimers() for any time-based logic beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); ``` **From Story 2.2 (DirectorsBoard):** ```javascript // Conditional base class pattern for ApplicationV2 test compatibility const _AppBase = typeof foundry !== 'undefined' ? ... : class _FallbackApp { ... }; ``` **From Story 2.3 (Bulk Actions):** ```javascript // Mock all dependencies with vi.fn() const adapter = { scenes: { current: vi.fn(), getFlag: vi.fn(), setFlag: vi.fn() }, users: { isGM: vi.fn(() => true), current: vi.fn(), all: vi.fn() }, hooks: { on: vi.fn(() => 42), off: vi.fn(), callAll: vi.fn() } }; ``` ### Test Categories **ScenePresetManager Tests:** - [ ] Constructor validation (throws on invalid adapter) - [ ] init() - loads presets from current scene - [ ] teardown() - cleans up hooks and state - [ ] save() - creates preset, persists to scene flag - [ ] save() - duplicate name detection - [ ] save() - max presets (50) enforcement - [ ] load() - applies preset matrix via StateStore.setMatrix() - [ ] load() - emits socket message - [ ] delete() - removes preset from scene flag - [ ] rename() - updates preset name with conflict detection - [ ] list() - returns all presets for current scene - [ ] Validation - all inputs validated before processing **PresetSaveDialog Tests:** - [ ] Renders with name input field - [ ] Validates preset name (non-empty, no special chars?) - [ ] Confirms overwrite when duplicate - [ ] Calls onSave callback with name - [ ] Calls onCancel callback - [ ] Keyboard support (Enter to save, Escape to cancel) - [ ] Accessibility (focus trap, ARIA labels) **PresetLoadDialog Tests:** - [ ] Renders list of presets - [ ] Handles empty preset list - [ ] Selection triggers load - [ ] Calls onLoad callback with preset name - [ ] Calls onCancel callback - [ ] Keyboard navigation (arrow keys, Enter) - [ ] Accessibility (focus trap, ARIA labels) ### Integration Testing **Critical:** Based on Epic 2 retrospective findings, add explicit integration tests: - [ ] ScenePresetManager.save() produces matrix compatible with StateStore.setMatrix() - [ ] Preset loading triggers correct socket events - [ ] NotificationBus receives and processes preset-applied events --- ## πŸ”„ Previous Story Intelligence **No previous stories in Epic 3** - This is the first story. ### Lessons from Epic 2 to Apply **βœ… DO REPEAT:** 1. **TDD Discipline (Stories 2.1-2.3)** - Write failing tests first (TDD red) - Implement to pass tests (TDD green) - Refactor while tests pass (TDD refactor) - **Pattern:** All stories had 20+ tests each 2. **Side-Effect-Free Constructors (All Stories)** - No hooks registration in constructor - No state mutation in constructor - init() method for setup, teardown() for cleanup - **Pattern:** Enabled reliable testing 3. **Import Boundary Enforcement (All Stories)** - ESLint `import/no-restricted-paths` catches violations - Strict layer separation (core/ β†’ contracts/, utils/) - **Pattern:** Zero boundary violations in Epic 2 4. **Deferred Debt Proactive Resolution (Story 2.1)** - Addressed 4 deferred items from Epic 1 - Prevented debt accumulation - **Pattern:** Address deferred work early 5. **Event Delegation on Root (Story 2.2, 2.3)** - Single listener on app root element - Uses `data-action` attributes for routing - Survives re-renders - **Pattern:** Robust event handling **⚠️ DO NOT REPEAT:** 1. **Cross-Story Interface Mismatches (Story 2.2 β†’ 2.3)** - `_dispatchToggle()` used wrong calling convention - Caught in Story 2.3, cost development time - **Mitigation:** Add integration test step to checklist 2. **Late Lifecycle Management (All Stories)** - `teardown()` methods added as fixes - Should be designed in from start - **Mitigation:** Include teardown in class design template 3. **Race Conditions in Bulk Operations (Story 2.3)** - State capture wasn't atomic - Caused inconsistent snapshots - **Mitigation:** Always capture complete state snapshot before iteration ### Patterns Established in Epic 2 1. **Bulk Operation Pattern** ```javascript // Capture snapshot first const snapshot = new Map(users.map(u => [u.id, this._stateStore.getState(u.id)])); // Then iterate for (const user of users) { const targetState = snapshot.get(user.id); this._controller.action('source', user.id, targetState, opId, baseRevision); } ``` 2. **Undo/Restore State Machine** ```javascript this._undoSnapshot = null; // Initial state // After bulk action: this._undoSnapshot = new Map(...); // After undo: this._undoSnapshot = null; // Single-step only ``` 3. **Atomic State Capture** ```javascript // WRONG: States may change between calls for (const user of users) { const state = this._stateStore.getState(user.id); // ❌ Race condition } // RIGHT: Capture all states first const allStates = users.map(u => this._stateStore.getState(u.id)); for (let i = 0; i < users.length; i++) { const state = allStates[i]; // βœ… Consistent snapshot } ``` --- ## πŸ“Š Git Intelligence Summary ### Recent Commit Patterns ``` 25b98ce 2026-05-23 Mark Story 2.3 code review as done cc5b04d 2026-05-23 Update sprint status: Story 2.3 code review completed and merged 7918792 2026-05-23 Fix Story 2.3 code review findings - Removed duplicate ParticipantCard.js - Fixed lint in ScryingPoolStrip.js ``` ### Code Patterns from Recent Commits 1. **Import Boundary Enforcement** - ESLint catches violations at lint time - No runtime enforcement needed 2. **Test File Structure** - Mirror source structure: `tests/unit/{path}/*.test.js` - Use `vi.fn()` for mocks - Use `beforeEach`/`afterEach` for setup/teardown 3. **Error Handling** - Validate all inputs - Use TypeError for invalid arguments - Wrap external calls in try-catch 4. **Code Review Findings Pattern** - Most issues caught in first review - Typical fixes: import boundary violations, missing validation, lint errors --- ## 🌐 Latest Technical Information ### FoundryVTT v14 API Considerations **Scene Flags:** - `Scene.setFlag(scope, key, value)` - Async, returns Promise - `Scene.getFlag(scope, key)` - Returns value or undefined - `Scene.unsetFlag(scope, key)` - Async, removes flag - **Scope:** Use `'video-view-manager'` for all module flags **Hooks:** - `Hooks.on('updateScene', (scene, data) => {...})` - Fires when scene is activated (Story 3.2 will use this) - **Note:** OQ-5 in architecture flags this as a timing concern - need to verify hook fires at correct time **Dialog API:** - `new Dialog({ title, content, buttons, default }).render(true)` - For simple prompts: `Dialog.prompt({ title, label, callback, reject })` - **Pattern:** Use native Foundry Dialog, not custom implementations ### Contract Validation **ScenePreset Contract (Already Implemented):** ```javascript // src/contracts/scene-preset.js - SCENE_PRESET_VERSION = 1 - MAX_PRESETS_PER_WORLD = 50 - createScenePreset(name, matrix, now) - isValidScenePreset(data) // Throws TypeError on violation ``` **Socket Message Contract (Already Implemented):** ```javascript // src/contracts/socket-message.js - SOCKET_EVENTS.PRESET_APPLY = "scrying-pool.preset.apply" - SOCKET_EVENTS.PRESET_APPLIED = "scrying-pool.preset.applied" - MAX_PAYLOAD_BYTES = 4096 ``` ### Performance Considerations - **Max Presets:** 50 per world (enforced) - **Max Payload:** 4096 bytes (enforced by SocketHandler) - **Matrix Size:** With 50 participants Γ— ~50 bytes per entry β‰ˆ 2500 bytes (safe) - **Scene Flag Size:** 50 presets Γ— ~200 bytes β‰ˆ 10KB per scene (acceptable) --- ## πŸ“š Project Context Reference ### Source Documents | Document | Location | Relevant Sections | |----------|----------|-------------------| | PRD | `_bmad-output/planning-artifacts/prds/prd-video-view-manager-2026-05-19/prd.md` | FR-15, FR-16, FR-17, FR-18, FR-19 | | Architecture | `_bmad-output/planning-artifacts/architecture.md` | Data Architecture, Core Decisions, OQ-5, OQ-6 | | Epics | `_bmad-output/planning-artifacts/epics.md` | Epic 3, Story 3.1-3.3, FR Coverage Map | | UX Design | `_bmad-output/planning-artifacts/ux-design-specification.md` | UX-DR14, UX-DR15, UX-DR16, UX-DR19 | | Epic 1 Retro | `_bmad-output/implementation-artifacts/epic-1-retro-2026-05-22.md` | Action items, lessons learned | | Epic 2 Retro | `_bmad-output/implementation-artifacts/epic-2-retro-2026-05-23.md` | Action items, patterns | | Deferred Work | `_bmad-output/implementation-artifacts/deferred-work.md` | Open items, technical debt | ### Related Stories | Story | Relevance | |-------|-----------| | 1-3 (Data Layer) | StateStore.setMatrix() method used for preset apply | | 1-4 (Core Logic) | ScryingPoolController.action() pattern for state changes | | 2-2 (Director's Board) | UI patterns for modal dialogs | | 2-3 (Bulk Actions) | Bulk state change patterns, undo logic | ### Architecture Decisions to Reference 1. **State Persistence Boundary (Architecture Β§Data Architecture)** - World settings: `scrying-pool.visibilityMatrix` - Scene flags: `{ _version: 1, presets: {...} }` - Client settings: `notificationVerbosity` - User flags: `firstBadgeEncounter`, `directorsBoardState` 2. **Socket Reconciliation (Architecture Β§Core Decisions)** - PendingOp lifecycle: create β†’ emit β†’ timeout/retry β†’ confirm/revert - Authoritative echo pattern - 3s timeout, 1 retry, then revert 3. **Dependency Injection (Architecture Β§Structural Constraint)** - Testable modules: zero direct `game.*` access - All Foundry deps constructor-injected via FoundryAdapter --- ## βœ… Story Completion Status **Status:** ready-for-dev **Ultimate Context Engine Analysis:** Completed ### Developer Readiness Checklist - [x] Story requirements extracted from epics.md - [x] Acceptance criteria in BDD format - [x] Technical requirements identified - [x] Architecture compliance verified - [x] Import boundaries defined - [x] Constructor patterns established - [x] Dependency injection contract defined - [x] File structure planned - [x] Testing requirements specified - [x] Previous story intelligence incorporated - [x] Git intelligence analyzed - [x] Latest tech information included - [x] Project context referenced ### Next Steps for Developer 1. **Review this story file** thoroughly 2. **Run** `dev-story` workflow with `story_key=3-1-save-and-load-scene-presets` 3. **Or manually implement** following the tasks below ### Implementation Order (Recommended) ``` 1. Create ScenePresetManager.js (Core logic) 2. Create PresetSaveDialog.js (UI component) 3. Create PresetLoadDialog.js (UI component) 4. Extend DirectorsBoard.js (UI integration) 5. Add templates 6. Add styles 7. Add i18n keys 8. Wire in module.js 9. Write unit tests 10. Run lint, typecheck, test ``` --- ## πŸ“ Tasks / Subtasks ### Task 1: Create `src/core/ScenePresetManager.js` **Files:** `src/core/ScenePresetManager.js`, `tests/unit/core/ScenePresetManager.test.js` **Subtasks:** - [ ] 1.1: Write TDD red tests in `ScenePresetManager.test.js` β€” constructor validation, init, teardown - [ ] 1.2: Implement `ScenePresetManager` class with constructor `(adapter, stateStore, socketHandler)` - [ ] 1.3: Implement `init()` β€” registers hooks if needed, loads current scene presets - [ ] 1.4: Implement `teardown()` β€” unregisters hooks, clears state - [ ] 1.5: Implement `save(name, matrix)` β€” validates name, checks max presets, creates preset, saves to scene flag - [ ] 1.6: Implement `load(name)` β€” retrieves preset, applies via StateStore.setMatrix(), emits socket message - [ ] 1.7: Implement `delete(name)` β€” removes preset from scene flag - [ ] 1.8: Implement `rename(oldName, newName)` β€” validates newName, checks conflicts, updates - [ ] 1.9: Implement `list()` β€” returns all presets for current scene - [ ] 1.10: Implement `_getScenePresets()` β€” reads from current scene flag, validates schema - [ ] 1.11: Implement `_saveScenePresets(presets)` β€” writes to current scene flag with validation - [ ] 1.12: Green all ScenePresetManager tests **Acceptance Criteria:** AC-1, AC-2, AC-3, AC-4, AC-5, AC-6, AC-7 --- ### Task 2: Create `src/ui/gm/PresetSaveDialog.js` **Files:** `src/ui/gm/PresetSaveDialog.js`, `src/ui/gm/PresetLoadDialog.js`, `tests/unit/ui/gm/PresetSaveDialog.test.js`, `tests/unit/ui/gm/PresetLoadDialog.test.js` **Subtasks:** - [ ] 2.1: Write TDD red tests for PresetSaveDialog - [ ] 2.2: Implement conditional base class pattern for ApplicationV2 compatibility - [ ] 2.3: Implement `PresetSaveDialog` extending base class - [ ] 2.4: Implement `static DEFAULT_OPTIONS` and `static PARTS` - [ ] 2.5: Implement `constructor(scenePresetManager, adapter)` - [ ] 2.6: Implement `async _prepareContext()` β€” returns context for template - [ ] 2.7: Implement `_onRender()` β€” wire form submit, cancel button - [ ] 2.8: Implement `_onSubmit(event)` β€” validate name, call manager.save(), close on success - [ ] 2.9: Implement `_onCancel()` β€” close dialog - [ ] 2.10: Implement keyboard support (Enter=save, Escape=cancel) - [ ] 2.11: Implement focus trap for accessibility - [ ] 2.12: Green all PresetSaveDialog tests **Acceptance Criteria:** AC-1, AC-2 --- ### Task 3: Create `src/ui/gm/PresetLoadDialog.js` **Files:** `src/ui/gm/PresetLoadDialog.js`, `tests/unit/ui/gm/PresetLoadDialog.test.js` **Subtasks:** - [ ] 3.1: Write TDD red tests for PresetLoadDialog - [ ] 3.2: Implement `PresetLoadDialog` extending same base class - [ ] 3.3: Implement `async _prepareContext()` β€” calls manager.list(), returns { presets } - [ ] 3.4: Implement `_onRender()` β€” wire preset selection, load button, cancel button - [ ] 3.5: Implement `_onLoad(presetName)` β€” call manager.load(presetName), close on success - [ ] 3.6: Implement `_onCancel()` β€” close dialog - [ ] 3.7: Implement empty state handling (no presets message) - [ ] 3.8: Implement keyboard navigation (arrow keys, Enter=load, Escape=cancel) - [ ] 3.9: Implement focus trap for accessibility - [ ] 3.10: Green all PresetLoadDialog tests **Acceptance Criteria:** AC-4, AC-5 --- ### Task 4: Extend `src/ui/gm/DirectorsBoard.js` **Files:** `src/ui/gm/DirectorsBoard.js`, `tests/unit/ui/gm/DirectorsBoard.test.js` **Subtasks:** - [ ] 4.1: Import ScenePresetManager in DirectorsBoard constructor - [ ] 4.2: Add `this._presetManager` field - [ ] 4.3: Update `_prepareContext()` to include preset count - [ ] 4.4: Update `_onRender()` to wire Save/Load buttons in footer - [ ] 4.5: Implement `_onSavePreset()` β€” opens PresetSaveDialog - [ ] 4.6: Implement `_onLoadPreset()` β€” opens PresetLoadDialog - [ ] 4.7: Enable Save/Load buttons in template (remove disabled attribute) - [ ] 4.8: Add i18n keys for button labels - [ ] 4.9: Update tests for new footer functionality **Acceptance Criteria:** AC-1, AC-4 --- ### Task 5: Create Templates **Files:** `templates/preset-save-dialog.hbs`, `templates/preset-load-dialog.hbs` **Subtasks:** - [ ] 5.1: Create `preset-save-dialog.hbs` β€” form with name input, Save/Cancel buttons - [ ] 5.2: All labels via `{{localize "video-view-manager.presets.save.*"}}` keys - [ ] 5.3: Add ARIA labels for accessibility - [ ] 5.4: Create `preset-load-dialog.hbs` β€” list of presets, Load/Cancel buttons - [ ] 5.5: Show empty state when no presets exist - [ ] 5.6: All labels via `{{localize "video-view-manager.presets.load.*"}}` keys - [ ] 5.7: Add ARIA labels for accessibility --- ### Task 6: Add Styles **Files:** `styles/components/_preset-save-dialog.less`, `styles/components/_preset-load-dialog.less`, `styles/components/_directors-board.less` **Subtasks:** - [ ] 6.1: Create `_preset-save-dialog.less` β€” form styling, input field, buttons - [ ] 6.2: All selectors scoped under `.scrying-pool` - [ ] 6.3: Use `--sp-*` tokens only (no direct Foundry tokens) - [ ] 6.4: Create `_preset-load-dialog.less` β€” list styling, preset items, buttons - [ ] 6.5: Style empty state message - [ ] 6.6: Update `_directors-board.less` β€” enable Save/Load buttons in footer --- ### Task 7: Wire in `module.js` **Files:** `module.js` **Subtasks:** - [ ] 7.1: Import `ScenePresetManager` - [ ] 7.2: Import `PresetSaveDialog` and `PresetLoadDialog` - [ ] 7.3: Declare `let scenePresetManager;` at module scope - [ ] 7.4: Declare `let presetSaveDialog;` and `let presetLoadDialog;` - [ ] 7.5: In `Hooks.once('ready')`, after `directorsBoard.init()`: - [ ] 7.5.1: Construct `scenePresetManager = new ScenePresetManager(adapter, stateStore, socketHandler)` - [ ] 7.5.2: Call `scenePresetManager.init()` - [ ] 7.5.3: Construct dialogs with manager reference - [ ] 7.6: Update header comment to include Story 3.1 wiring --- ### Task 8: Add i18n Keys **Files:** `lang/en.json` **Subtasks:** - [ ] 8.1: Add `video-view-manager.presets.save.title` = "Save Scene Preset" - [ ] 8.2: Add `video-view-manager.presets.save.nameLabel` = "Preset Name" - [ ] 8.3: Add `video-view-manager.presets.save.nameHint` = "Enter a unique name for this camera layout" - [ ] 8.4: Add `video-view-manager.presets.save.saveButton` = "Save" - [ ] 8.5: Add `video-view-manager.presets.save.cancelButton` = "Cancel" - [ ] 8.6: Add `video-view-manager.presets.save.duplicateWarning` = "A preset with this name already exists. Overwrite?" - [ ] 8.7: Add `video-view-manager.presets.save.maxReached` = "Maximum of 50 presets reached. Delete an existing preset to save a new one." - [ ] 8.8: Add `video-view-manager.presets.load.title` = "Load Scene Preset" - [ ] 8.9: Add `video-view-manager.presets.load.emptyMessage` = "No presets saved yet." - [ ] 8.10: Add `video-view-manager.presets.load.loadButton` = "Load" - [ ] 8.11: Add `video-view-manager.presets.load.cancelButton` = "Cancel" - [ ] 8.12: Add `video-view-manager.presets.load.confirmDelete` = "Are you sure you want to delete this preset?" - [ ] 8.13: Add `video-view-manager.presets.notifications.applied` = "GM applied preset: {name}" --- ### Task 9: Socket Handler Extensions (if needed) **Files:** `src/core/SocketHandler.js` (if preset events need special handling) **Subtasks:** - [ ] 9.1: Evaluate if PRESET_APPLY/PRESET_APPLIED need special handling - [ ] 9.2: If yes, extend SocketHandler to support preset events - [ ] 9.3: Add tests for preset socket handling **Note:** The socket message contract already defines these events. Check if SocketHandler needs updates to handle them. --- ### Task 10: Pipeline Verification **Subtasks:** - [ ] 10.1: `npm run lint` exits 0 for all modified/new files - [ ] 10.2: `npm run typecheck` exits 0 - [ ] 10.3: `npm run test` exits 0 (target: 412 + 60-80 = 472-492 tests) - [ ] 10.4: Manual testing of all acceptance criteria --- ## 🎯 Dev Agent Guardrails ### Critical Architecture Rules 1. **NO direct `game.*` access in testable modules** - ScenePresetManager, PresetSaveDialog, PresetLoadDialog must be fully testable - All Foundry API access via injected adapter 2. **Import boundaries MUST be respected** - `src/core/` β†’ `src/contracts/`, `src/utils/` ONLY - `src/ui/` β†’ `src/core/`, `src/contracts/`, `src/utils/` ONLY - ESLint will catch violations 3. **Side-effect-free constructors** - NO hook registration in constructors - NO state mutation in constructors - Use init() for setup, teardown() for cleanup 4. **All user-facing strings MUST use i18n** - Use `adapter.i18n.localize()` or `{{localize "..."}}` in templates - NO hardcoded English strings 5. **All interactive elements MUST be accessible** - Keyboard navigation - ARIA labels - Focus management 6. **All socket payloads MUST be validated** - Use contract validators (isValidScenePreset, etc.) - Validate before sending AND receiving --- ## πŸ“Œ Implementation Notes ### ScenePresetManager Design ```javascript // src/core/ScenePresetManager.js import { createScenePreset, isValidScenePreset, MAX_PRESETS_PER_WORLD } from '../contracts/scene-preset.js'; import { SOCKET_EVENTS, createSocketIntentMessage } from '../contracts/socket-message.js'; /** * Manages scene preset CRUD operations. * Persists presets to Scene document flags. * Emits socket messages for preset apply operations. * * Import rule: may only import from src/contracts/ and src/utils/. */ export class ScenePresetManager { /** * @param {import('../foundry/FoundryAdapter.js').FoundryAdapter} adapter * @param {import('./StateStore.js').StateStore} stateStore * @param {import('./SocketHandler.js').SocketHandler} socketHandler */ constructor(adapter, stateStore, socketHandler) { this._adapter = adapter; this._stateStore = stateStore; this._socketHandler = socketHandler; this._presetsCache = new Map(); // name β†’ ScenePreset } init() { // No hooks needed for basic CRUD // Hooks would be for auto-apply (Story 3.2) this._loadCurrentScenePresets(); } teardown() { this._presetsCache.clear(); } /** * Saves the current Visibility Matrix as a named preset. * @param {string} name - Preset name (non-empty string) * @returns {Promise} * @throws {TypeError} If name is invalid or max presets reached */ async save(name) { // 1. Validate name // 2. Check max presets // 3. Get current matrix from StateStore // 4. Create preset // 5. Add to scene flag // 6. Persist // 7. Return preset } /** * Loads a preset by name, applying its matrix. * @param {string} name - Preset name * @returns {Promise} * @throws {TypeError} If preset not found */ async load(name) { // 1. Get preset from scene flag // 2. Apply matrix via StateStore.setMatrix() // 3. Emit socket message (PRESET_APPLY) // 4. Emit notification via adapter.notifications // 5. Update cache } // ... delete, rename, list methods } ``` ### Dialog UI Patterns Follow the established pattern from DirectorsBoard: - Conditional base class for ApplicationV2 compatibility - Event delegation on root element - Side-effect-free constructor - init() for hook registration - teardown() for cleanup ### Preset Storage Format ```javascript // On Scene document flag: { "_version": 1, "presets": { "Combat View": { "_version": 1, "name": "Combat View", "matrix": { "user1": "active", "user2": "hidden", "user3": "active" }, "createdAt": 1716451200000, "updatedAt": 1716451200000 }, "Social Scene": { "_version": 1, "name": "Social Scene", "matrix": { "user1": "active", "user2": "active", "user3": "active" }, "createdAt": 1716537600000, "updatedAt": 1716537600000 } } } ``` ### Notification Patterns Use existing NotificationBus or direct adapter.notifications: ```javascript // For preset applied notification (AC-5): this._adapter.notifications.info( game.i18n.localize('video-view-manager.presets.notifications.applied') .replace('{name}', presetName) ); ``` --- ## πŸ” References ### Story-Specific References - **Epics:** [Source: _bmad-output/planning-artifacts/epics.md#Story 3.1] - **PRD:** FR-15, FR-16 [Source: _bmad-output/planning-artifacts/prds/prd-video-view-manager-2026-05-19/prd.md#4.4] - **Architecture:** Scene Preset JSON schema [Source: _bmad-output/planning-artifacts/architecture.md#Data Architecture] - **UX Design:** UX-DR14, UX-DR15, UX-DR16 [Source: _bmad-output/planning-artifacts/ux-design-specification.md] - **Contracts:** scene-preset.js, socket-message.js [Source: src/contracts/] ### Dependency References - **StateStore.setMatrix():** [Source: src/core/StateStore.js:118-142] - **ScryingPoolController.action():** [Source: src/core/ScryingPoolController.js:100-160] - **DirectorsBoard pattern:** [Source: src/ui/gm/DirectorsBoard.js] - **SocketHandler.emit():** [Source: src/core/SocketHandler.js:56-95] - **NotificationBus pattern:** [Source: src/notifications/NotificationBus.js] ### Previous Story Patterns - **Modal dialog pattern:** [Source: Story 2.2 - DirectorsBoard as ApplicationV2] - **Bulk operation pattern:** [Source: Story 2.3 - showAll/hideAll/spotlight] - **State machine pattern:** [Source: Story 2.3 - _undoSnapshot/_spotlightSnapshot] - **TDD pattern:** [Source: All Epic 2 stories] --- ## πŸ“ Dev Notes ### File Location Notes **New Directory:** None - all files fit in existing directory structure **Import Boundary Reminder:** ``` src/core/ScenePresetManager.js β†’ may import: src/contracts/, src/utils/ ONLY src/ui/gm/PresetSaveDialog.js β†’ may import: src/core/, src/contracts/, src/utils/ ONLY src/ui/gm/PresetLoadDialog.js β†’ may import: src/core/, src/contracts/, src/utils/ ONLY ``` ### Constructor Pattern Reminder All new classes must be side-effect-free: ```javascript // βœ… CORRECT export class ScenePresetManager { constructor(adapter, stateStore, socketHandler) { this._adapter = adapter; // Store only, no side effects this._stateStore = stateStore; this._socketHandler = socketHandler; this._presetsCache = new Map(); // Initialize internal state } init() { // Side effects here this._loadCurrentScenePresets(); } } // ❌ WRONG - Side effects in constructor constructor(adapter) { this._hookId = Hooks.on('someEvent', this._handler.bind(this)); // Side effect! } ``` ### Test Mock Patterns Use the established mock patterns from Epic 2: ```javascript // From tests/helpers/foundryAdapterMock.js (create if doesn't exist) export function createFoundryAdapterMock(overrides = {}) { return { scenes: { current: vi.fn(() => ({ id: 'scene1', getFlag: vi.fn(), setFlag: vi.fn() })), ...overrides.scenes, }, users: { isGM: vi.fn(() => true), current: vi.fn(() => ({ id: 'gm1', name: 'GM' })), all: vi.fn(() => []), ...overrides.users, }, hooks: { on: vi.fn(() => 42), off: vi.fn(), callAll: vi.fn(), ...overrides.hooks, }, socket: { emit: vi.fn(), on: vi.fn(), off: vi.fn(), ...overrides.socket, }, notifications: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), ...overrides.notifications, }, i18n: { localize: vi.fn((key) => key), ...overrides.i18n, }, }; } ``` ### Keyboard Shortcut Considerations **Note:** Story 3.1 does NOT require new keyboard shortcuts. The Save/Load actions are triggered via UI buttons. Keyboard shortcuts for presets (if any) would be in Story 3.2 or 3.3. ### Accessibility Requirements All dialogs must: - Have `role="dialog"` or use native `` element - Be focus-trapped - Support Escape to close - Have all interactive elements keyboard-accessible - Have appropriate ARIA labels ### Performance Considerations - Preset list rendering should be efficient (virtualize if >20 presets) - Matrix serialization/deserialization should be fast - Scene flag reads/writes are async - handle promises correctly --- ## 🎯 Completion Checklist - [ ] All tasks 1-10 completed - [ ] All acceptance criteria pass manual testing - [ ] All unit tests pass (60-80 new tests) - [ ] `npm run lint` exits 0 - [ ] `npm run typecheck` exits 0 - [ ] `npm run test` exits 0 - [ ] Code review completed with no critical findings - [ ] Story file updated with actual implementation notes - [ ] Sprint status updated to "in-progress" when development starts - [ ] Sprint status updated to "review" when ready for code review - [ ] Sprint status updated to "done" after code review passes --- *Generated by BMad Method Ultimate Story Context Engine - 2026-05-23*