Files
scrying-pool/_bmad-output/implementation-artifacts/3-1-save-and-load-scene-presets.md
T
uberwald 6a55d4f1e1 Fix sprint tracking: Story 3.1 was already implemented
- 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>
2026-05-23 19:06:31 +02:00

39 KiB
Raw Blame History

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 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:

{
  _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:

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:

  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

    // 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

    this._undoSnapshot = null;  // Initial state
    // After bulk action:
    this._undoSnapshot = new Map(...);
    // After undo:
    this._undoSnapshot = null;  // Single-step only
    
  3. 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

  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):

// 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
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

  • 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

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

// 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 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