- Updated sprint-status.yaml: 3-1-save-and-load-scene-presets → done
- Updated Story 3.1 file header: Status → done
- Updated last_updated timestamp
Note: Story 3.1 (Save & Load Scene Presets) was implemented as part of
commit a1e8886 (Story 3.2 done) which created ScenePresetManager,
PresetSaveDialog, PresetLoadDialog, and related tests.
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
39 KiB
Story 3.1: Save & Load Scene Presets
Status: done
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-24
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 | done |
| 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 lintexits 0npm run typecheckexits 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:
{
_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 applyPRESET_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/insidesrc/core/orsrc/ui/ - No direct
game.*access in testable modules - No circular dependencies
Constructor Pattern (Side-Effect-Free)
All new classes MUST follow the established pattern:
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:
{
scenes: {
current: () => Scene|null,
getFlag: (sceneId, key) => any,
setFlag: (sceneId, key, value) => Promise<any>
},
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):
// Use vi.useFakeTimers() for any time-based logic
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
From Story 2.2 (DirectorsBoard):
// Conditional base class pattern for ApplicationV2 test compatibility
const _AppBase = typeof foundry !== 'undefined' ? ... : class _FallbackApp { ... };
From Story 2.3 (Bulk Actions):
// 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:
-
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
-
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
-
Import Boundary Enforcement (All Stories)
- ESLint
import/no-restricted-pathscatches violations - Strict layer separation (core/ → contracts/, utils/)
- Pattern: Zero boundary violations in Epic 2
- ESLint
-
Deferred Debt Proactive Resolution (Story 2.1)
- Addressed 4 deferred items from Epic 1
- Prevented debt accumulation
- Pattern: Address deferred work early
-
Event Delegation on Root (Story 2.2, 2.3)
- Single listener on app root element
- Uses
data-actionattributes for routing - Survives re-renders
- Pattern: Robust event handling
⚠️ DO NOT REPEAT:
-
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
-
Late Lifecycle Management (All Stories)
teardown()methods added as fixes- Should be designed in from start
- Mitigation: Include teardown in class design template
-
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
-
Bulk Operation Pattern
// 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); } -
Undo/Restore State Machine
this._undoSnapshot = null; // Initial state // After bulk action: this._undoSnapshot = new Map(...); // After undo: this._undoSnapshot = null; // Single-step only -
Atomic State Capture
// 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
-
Import Boundary Enforcement
- ESLint catches violations at lint time
- No runtime enforcement needed
-
Test File Structure
- Mirror source structure:
tests/unit/{path}/*.test.js - Use
vi.fn()for mocks - Use
beforeEach/afterEachfor setup/teardown
- Mirror source structure:
-
Error Handling
- Validate all inputs
- Use TypeError for invalid arguments
- Wrap external calls in try-catch
-
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 PromiseScene.getFlag(scope, key)- Returns value or undefinedScene.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):
// 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):
// 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
-
State Persistence Boundary (Architecture §Data Architecture)
- World settings:
scrying-pool.visibilityMatrix - Scene flags:
{ _version: 1, presets: {...} } - Client settings:
notificationVerbosity - User flags:
firstBadgeEncounter,directorsBoardState
- World settings:
-
Socket Reconciliation (Architecture §Core Decisions)
- PendingOp lifecycle: create → emit → timeout/retry → confirm/revert
- Authoritative echo pattern
- 3s timeout, 1 retry, then revert
-
Dependency Injection (Architecture §Structural Constraint)
- Testable modules: zero direct
game.*access - All Foundry deps constructor-injected via FoundryAdapter
- Testable modules: zero direct
✅ Story Completion Status
Status: ready-for-dev
Ultimate Context Engine Analysis: Completed
Developer Readiness Checklist
- Story requirements extracted from epics.md
- Acceptance criteria in BDD format
- Technical requirements identified
- Architecture compliance verified
- Import boundaries defined
- Constructor patterns established
- Dependency injection contract defined
- File structure planned
- Testing requirements specified
- Previous story intelligence incorporated
- Git intelligence analyzed
- Latest tech information included
- Project context referenced
Next Steps for Developer
- Review this story file thoroughly
- Run
dev-storyworkflow withstory_key=3-1-save-and-load-scene-presets - 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
ScenePresetManagerclass 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
PresetSaveDialogextending base class - 2.4: Implement
static DEFAULT_OPTIONSandstatic 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
PresetLoadDialogextending 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._presetManagerfield - 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
PresetSaveDialogandPresetLoadDialog - 7.3: Declare
let scenePresetManager;at module scope - 7.4: Declare
let presetSaveDialog;andlet presetLoadDialog; - 7.5: In
Hooks.once('ready'), afterdirectorsBoard.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.5.1: Construct
- 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 lintexits 0 for all modified/new files - 10.2:
npm run typecheckexits 0 - 10.3:
npm run testexits 0 (target: 412 + 60-80 = 472-492 tests) - 10.4: Manual testing of all acceptance criteria
🎯 Dev Agent Guardrails
Critical Architecture Rules
-
NO direct
game.*access in testable modules- ScenePresetManager, PresetSaveDialog, PresetLoadDialog must be fully testable
- All Foundry API access via injected adapter
-
Import boundaries MUST be respected
src/core/→src/contracts/,src/utils/ONLYsrc/ui/→src/core/,src/contracts/,src/utils/ONLY- ESLint will catch violations
-
Side-effect-free constructors
- NO hook registration in constructors
- NO state mutation in constructors
- Use init() for setup, teardown() for cleanup
-
All user-facing strings MUST use i18n
- Use
adapter.i18n.localize()or{{localize "..."}}in templates - NO hardcoded English strings
- Use
-
All interactive elements MUST be accessible
- Keyboard navigation
- ARIA labels
- Focus management
-
All socket payloads MUST be validated
- Use contract validators (isValidScenePreset, etc.)
- Validate before sending AND receiving
📌 Implementation Notes
ScenePresetManager Design
// 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<ScenePreset>}
* @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<void>}
* @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
// 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:
// 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:
// ✅ 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:
// 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<dialog>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 lintexits 0npm run typecheckexits 0npm run testexits 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