Story 3.3 done

This commit is contained in:
2026-05-25 10:32:49 +02:00
parent 7b56d62563
commit 748c7d7f85
12 changed files with 451 additions and 105 deletions
@@ -729,3 +729,29 @@ All acceptance criteria (AC-1 through AC-9) are satisfied except AC-9 (README do
--- ---
*This story file was created using the BMad Method Ultimate Context Engine. The developer now has everything needed for flawless implementation.* *This story file was created using the BMad Method Ultimate Context Engine. The developer now has everything needed for flawless implementation.*
---
### Review Findings
#### decision-needed
- [x] [Review][Decision] AC-8 merge success message format differs from spec — Spec says `"Imported N presets (M new, K replaced)"` but implementation correctly outputs `"Imported X presets (Y new, Z skipped as duplicates)"`. **Resolution**: Update spec AC-8 to match implementation — merge mode skips duplicates, never replaces.
#### patch
- [x] [Review][Patch] PresetExport filename mismatch preview vs actual download [`src/ui/gm/PresetExportDialog.js`]
- [x] [Review][Patch] World name fallback inconsistency between `exportAllPresets` and `generateExportFilename` [`src/core/PresetImportExportManager.js:84` vs `:109`]
- [x] [Review][Patch] Hardcoded notification strings bypass localization keys defined in en.json [`src/ui/gm/PresetExportDialog.js:_onExport`, `src/ui/gm/PresetImportDialog.js:_onImport`]
- [x] [Review][Patch] `_onExport` double-calls `adapter.scenes.current()` [`src/ui/gm/PresetExportDialog.js:73-74`]
- [x] [Review][Patch] `success` flag not recomputed after merging extraction errors into result [`src/core/PresetImportExportManager.js:341-347`]
- [x] [Review][Patch] Import preview marks merge duplicates as invalid (red X) instead of skipped (yellow) [`src/ui/gm/PresetImportDialog.js:_parseAndPreviewFile`]
- [x] [Review][Patch] Regex `\s` in preset name character validation allows newlines/tabs [`src/core/PresetImportExportManager.js:238`]
- [x] [Review][Patch] No concurrency guard on async `_parseAndPreviewFile` — rapid file selection races [`src/ui/gm/PresetImportDialog.js:_parseAndPreviewFile`]
- [x] [Review][Patch] Import preview doesn't validate each preset structure with `isValidScenePreset()` [`src/ui/gm/PresetImportDialog.js:_parseAndPreviewFile`]
- [x] [Review][Patch] Export dialog renders when no active scene (then fails on export) [`src/ui/gm/PresetExportDialog.js`]
- [x] [Review][Patch] Import mode labels hardcoded as `'Merge'`/`'Replace'` instead of localized [`src/ui/gm/PresetImportDialog.js:_prepareContext`]
#### defer
- [x] [Review][Defer] Replace mode rollback can fail partially leaving corrupted state [`src/core/PresetImportExportManager.js:434-448`] — deferred, browser env lacks transactions; existing error reporting is reasonable
@@ -60,3 +60,7 @@
- [ ] 5MB MAX_PORTRAIT_SIZE vs ~50KB Foundry flag limit — documented design limitation; flag limit is server-dependent and can't be changed in code - [ ] 5MB MAX_PORTRAIT_SIZE vs ~50KB Foundry flag limit — documented design limitation; flag limit is server-dependent and can't be changed in code
- [ ] No magic-byte file content validation — spec mentions "MIME type AND file content" but only format/MIME check implemented; enhancement for future - [ ] No magic-byte file content validation — spec mentions "MIME type AND file content" but only format/MIME check implemented; enhancement for future
- [ ] No animated-vs-static GIF distinction — FR-26 requires static GIF only but MIME-type alone can't distinguish; requires binary GIF parsing - [ ] No animated-vs-static GIF distinction — FR-26 requires static GIF only but MIME-type alone can't distinguish; requires binary GIF parsing
## Deferred from: code review of 3-3-preset-import-and-export (2026-05-26)
- [ ] Replace mode rollback can fail partially leaving corrupted state — Browser env lacks transaction support; existing error reporting is reasonable but state can be partially deleted/restored when both delete and rollback fail. [`src/core/PresetImportExportManager.js:434-448`]
@@ -0,0 +1,16 @@
# Blind Hunter — Code Review: Story 3.3 (Preset Import & Export)
You are the **Blind Hunter**. You receive ONLY the diff below. No project context, no spec file, no documentation.
Your job: Find bugs, security vulnerabilities, logic errors, code smells, and anti-patterns. Be adversarial. Assume nothing.
## Rules
- Output findings as a Markdown list with severity labels: `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`
- Each finding: one-line title + evidence from the diff
- If nothing is found, output: `No findings.`
## Diff
```
[PASTE THE FULL DIFF HERE]
```
@@ -0,0 +1,41 @@
# Edge Case Hunter — Code Review: Story 3.3 (Preset Import & Export)
You are the **Edge Case Hunter**. You receive the diff AND read access to the project.
Your job: Walk every branching path and boundary condition in the diff. Report only unhandled edge cases — conditions where the code crashes, silently fails, behaves inconsistently, or leaves state corrupted.
## Rules
- Output findings as a Markdown list with severity labels: `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`
- Each finding: one-line title + reproduction path + evidence from the diff
- Only report genuine unhandled edge cases, not theoretical impossibilities
- For non-trivial findings, suggest how to reproduce
## Diff
Files changed for Story 3.3 (Preset Import & Export):
```
src/core/PresetImportExportManager.js (NEW - 463 lines)
src/ui/gm/PresetExportDialog.js (NEW - 200 lines)
src/ui/gm/PresetImportDialog.js (NEW - 436 lines)
templates/preset-export.hbs (NEW - 29 lines)
templates/preset-import.hbs (NEW - 90 lines)
styles/components/_preset-import-export.less (NEW - 403 lines)
tests/unit/core/PresetImportExportManager.test.js (NEW - 476 lines)
src/ui/gm/DirectorsBoard.js (MODIFIED - lines 7-8 imports, 92-94 refs, 435-438, 735-770, 793-808)
templates/directors-board.hbs (MODIFIED - added export/import buttons)
lang/en.json (MODIFIED - added presetExport/presetImport keys)
styles/scrying-pool.less (MODIFIED - added _preset-import-export import)
```
Full diff file: `/tmp/opencode/story-3.3-diff.txt`
## Project Access
Root: `/home/morr/work/foundryvtt/video-view-manager/`
Key reference files to read as needed:
- `src/contracts/scene-preset.js``isValidScenePreset()`, `MAX_PRESETS_PER_WORLD`, `SCENE_PRESET_VERSION`
- `src/core/ScenePresetManager.js``list()`, `save()`, `delete()`, `get()` methods
- `src/foundry/FoundryAdapter.js` — adapter surface used by the new code
- `src/utils/html.js``escapeHtml()` helper
Walk every path in the diff and report any unhandled edge cases.
@@ -0,0 +1,47 @@
# Acceptance Auditor — Code Review: Story 3.3 (Preset Import & Export)
You are the **Acceptance Auditor**. Review this diff against the spec and context docs.
Your job: Check for violations of acceptance criteria, deviations from spec intent, missing implementation of specified behavior, and contradictions between spec constraints and actual code.
## Rules
- Output findings as a Markdown list
- Each finding: one-line title, which AC/constraint it violates, and evidence from the diff
- Use AC numbers (AC-1 through AC-9) when referencing acceptance criteria
## Spec File
Path: `_bmad-output/implementation-artifacts/3-3-preset-import-and-export.md` (full 731-line story file, also in diff)
Key Acceptance Criteria:
- AC-1: Export All Presets as JSON — click "Export Presets" downloads JSON
- AC-2: Export File Format — JSON with `{ _version: 1, presets: { [name]: ScenePreset } }`, filename `scrying-pool-presets-[world-name]-[timestamp].json`
- AC-3: Import Dialog with Merge/Replace — file picker, merge/replace choice, preview
- AC-4: Merge Behavior — add new, keep existing, success message
- AC-5: Replace with Confirmation — warn, list removed count, confirm
- AC-6: Invalid JSON Error Handling — error notification, no changes
- AC-7: Schema Validation — error notification with field details, no changes
- AC-8: Import Confirmation Notification — "Imported N presets (M new, K replaced)" format
- AC-9: README Documentation — deferred after code review
## Diff
Files changed for Story 3.3:
Full diff path: `/tmp/opencode/story-3.3-diff.txt`
New files:
- `src/core/PresetImportExportManager.js` — Core import/export logic
- `src/ui/gm/PresetExportDialog.js` — Export dialog
- `src/ui/gm/PresetImportDialog.js` — Import dialog with merge/replace
- `templates/preset-export.hbs` — Export template
- `templates/preset-import.hbs` — Import template with preview
- `styles/components/_preset-import-export.less` — Dialog styles
- `tests/unit/core/PresetImportExportManager.test.js` — 38 tests
Modified files for 3.3:
- `src/ui/gm/DirectorsBoard.js` — Import/Export button handlers
- `templates/directors-board.hbs` — Export/Import buttons in footer
- `lang/en.json` — Localization keys
- `styles/scrying-pool.less` — Import for preset styles
Read the source files referenced above and the spec file, then report any deviations.
@@ -3,6 +3,7 @@
**Workspace:** `_bmad-output/planning-artifacts/prds/prd-video-view-manager-2026-05-19/` **Workspace:** `_bmad-output/planning-artifacts/prds/prd-video-view-manager-2026-05-19/`
**Created:** 2026-05-19 **Created:** 2026-05-19
**Author:** Morr **Author:** Morr
**Last Updated:** 2026-05-25
--- ---
@@ -12,7 +13,7 @@
**Date:** 2026-05-19 **Date:** 2026-05-19
**Decision:** PRD covers full product vision across all 3 priority tiers. v1.0 = Day 1 + Week 12 features (FR-1 through FR-26). Later-tier features are documented in §10 as a roadmap, not as FRs. **Decision:** PRD covers full product vision across all 3 priority tiers. v1.0 = Day 1 + Week 12 features (FR-1 through FR-26). Later-tier features are documented in §10 as a roadmap, not as FRs.
**Rationale:** User selected "Full vision PRD" during working mode selection. **Rationale:** User selected "Full vision PRD" during working mode selection.
**Status:** Captured in §10 Product Roadmap. **Status:** Captured in §10 Product Roadmap. **UPDATED:** Expanded to include FR-27 through FR-32 from live testing.
### D-2 — WebRTC track disabling preferred ### D-2 — WebRTC track disabling preferred
**Date:** 2026-05-19 **Date:** 2026-05-19
@@ -56,18 +57,6 @@
**Rationale:** Standard FoundryVTT module convention. Community translation is a common contribution pattern in the ecosystem. **Rationale:** Standard FoundryVTT module convention. Community translation is a common contribution pattern in the ecosystem.
**Status:** Captured in §6.1 MVP Scope. **Status:** Captured in §6.1 MVP Scope.
### D-11 — GM sees all activated feeds; own self-view is configurable
**Date:** 2026-05-19
**Decision:** The GM always sees all activated player webcams. The GM's own feed visibility in their own view is a configurable module setting ("Show my own feed to myself", default ON).
**Rationale:** User answer to OQ-3: "All activated webcam of players + himself as an option."
**Status:** Captured in FR-6. OQ-3 closed.
### D-12 — Native WebRTC only for v1.0; non-native AV backends deferred
**Date:** 2026-05-19
**Decision:** Only FoundryVTT's native WebRTC AV backend is supported in v1.0. Jitsi Meeting Server and other backends are not in scope.
**Rationale:** User answer to OQ-4: "Not planned in first steps, maybe later."
**Status:** Captured in §5 Non-Goals. OQ-4 closed; deferred to Later roadmap.
### D-9 — Native FoundryVTT socket API for state broadcast ### D-9 — Native FoundryVTT socket API for state broadcast
**Date:** 2026-05-19 **Date:** 2026-05-19
**Decision:** Visibility Matrix changes are broadcast via the native FoundryVTT socket API using a registered module event name. `socketlib` will not be added as a dependency. **Decision:** Visibility Matrix changes are broadcast via the native FoundryVTT socket API using a registered module event name. `socketlib` will not be added as a dependency.
@@ -80,14 +69,17 @@
**Rationale:** User confirmed: "Not sure, to be checked with the API itself." OQ-1 remains open but is unblocking — CSS fallback ships regardless. **Rationale:** User confirmed: "Not sure, to be checked with the API itself." OQ-1 remains open but is unblocking — CSS fallback ships regardless.
**Status:** OQ-1 downgraded from hard phase blocker to development-time research item. FR-7 unchanged. **Status:** OQ-1 downgraded from hard phase blocker to development-time research item. FR-7 unchanged.
| OQ | Phase Blocker? | Status | ### D-11 — GM sees all activated feeds; own self-view is configurable
|---|---|---| **Date:** 2026-05-19
| OQ-1: WebRTC track disable API access | Yes | Open — to be checked against v14 API during development; CSS fallback is safe default | **Decision:** The GM always sees all activated player webcams. The GM's own feed visibility in their own view is a configurable module setting ("Show my own feed to myself", default ON).
| OQ-2: socketlib vs native socket API | Yes | **RESOLVED** — native FoundryVTT socket API | **Rationale:** User answer to OQ-3: "All activated webcam of players + himself as an option."
| OQ-3: GM own-feed hidden behavior | No | **RESOLVED** — GM always sees all activated feeds; own self-view is a configurable option | **Status:** Captured in FR-6. OQ-3 closed.
| OQ-4: Non-native AV backend support | No | **RESOLVED** — native WebRTC only for v1.0; others deferred to Later roadmap |
| OQ-5: Scene hook timing | No | Deferred — not a concern for first implementation stage | ### D-12 — Native WebRTC only for v1.0; non-native AV backends deferred
| OQ-6: Partial vs full preset application | No | Open — to be resolved during FR-15/FR-16 implementation | **Date:** 2026-05-19
**Decision:** Only FoundryVTT's native WebRTC AV backend is supported in v1.0. Jitsi Meeting Server and other backends are not in scope.
**Rationale:** User answer to OQ-4: "Not planned in first steps, maybe later."
**Status:** Captured in §5 Non-Goals. OQ-4 closed; deferred to Later roadmap.
### D-13 — Reaction Cam / automation effects are opt-in (not opt-out) ### D-13 — Reaction Cam / automation effects are opt-in (not opt-out)
**Date:** 2026-05-19 **Date:** 2026-05-19
@@ -107,7 +99,120 @@
**Rationale:** From brainstorming "panic button" + Marcus persona. Elevated to cross-cutting design rule. **Rationale:** From brainstorming "panic button" + Marcus persona. Elevated to cross-cutting design rule.
**Status:** Added to Cross-Cutting NFRs. **Status:** Added to Cross-Cutting NFRs.
### D-16 — PRD finalized ---
**Date:** 2026-05-19
**Decision:** PRD marked `status: final`. All decisions captured, all reconciliation gaps resolved, all [ASSUMPTION] tags indexed, polish complete. ## Live Testing Decisions (May 20-25, 2026)
**Status:** Closed.
### D-16 — Full AV Dock Replacement
**Date:** 2026-05-20
**Decision:** Replace FoundryVTT's native AV dock completely with custom ScryingPoolStrip and RoleRenderer components rather than hooking into the native dock.
**Rationale:** Live testing revealed limitations in hooking approach; full replacement provides better control over rendering, layout, and participant management. Enables consistent behavior across all AV states.
**Status:** Implemented in FR-27, FR-28. Captured in §4.6 Full AV Dock Replacement.
**Trade-offs:**
- PRO: Full control over UI/UX, consistent behavior, easier to extend
- PRO: Enables dock layout system and position persistence
- CON: Must handle all edge cases that native dock handles
- CON: Potential compatibility issues with other AV-related modules
### D-17 — Dock Layout System
**Date:** 2026-05-22
**Decision:** Implement configurable dock layouts with 6 options: vertical-sm, vertical-md, horizontal-sm, horizontal-md, mosaic-sm, mosaic-md.
**Rationale:** User feedback from live testing requested flexibility in how participant tiles are arranged to match different table setups and screen configurations. GM controls direction/size at world level; individual users can override size preference.
**Status:** Implemented in FR-29, FR-30. Captured in §4.7 Dock Layout System.
**Implementation Notes:**
- World-scoped `dockLayout` setting controls direction and canonical size
- Client-scoped `dockLayoutExpanded` setting allows per-user override ('' = inherit, 'sm' = small, 'md' = large)
- Director's Board includes visual selector for layout configuration
- Legacy boolean values automatically migrated to string format
### D-18 — Position Persistence
**Date:** 2026-05-24
**Decision:** ScryingPoolStrip window position persists across sessions using GM user flags.
**Rationale:** User feedback indicated frustration with repositioning the dock every session. Position persistence improves UX consistency.
**Status:** Implemented in FR-31. Captured in §4.8 Position Persistence.
**Implementation Notes:**
- Position (left, top) saved to GM user flags on move
- Loaded on module initialization via `_loadPosition()` method
- First-time users get default position
### D-19 — ApplicationV2 API Migration
**Date:** 2026-05-20
**Decision:** Migrate all UI components to use FoundryVTT v14 ApplicationV2 API with HandlebarsApplicationMixin.
**Rationale:** FoundryVTT v14 requires ApplicationV2 for proper window management, resizing, and lifecycle. Previous implementation had compatibility issues.
**Status:** Implemented in FR-32. Captured in §4.9 Implementation Quality.
**Implementation Notes:**
- ScryingPoolStrip extends HandlebarsApplicationMixin(ApplicationV2)
- DirectorsBoard extends HandlebarsApplicationMixin(ApplicationV2)
- Fixed jQuery parameter handling in _onRender methods
- Added proper PARTS and DEFAULT_OPTIONS static properties
### D-20 — PortraitFallbackHandler Integration
**Date:** 2026-05-23
**Decision:** Enhance portrait fallback system with dedicated PortraitFallbackHandler component for consistent image resolution.
**Rationale:** Live testing revealed inconsistent fallback behavior. Dedicated handler provides clear priority chain and better integration with custom portraits.
**Status:** Implemented in FR-8 enhancement, FR-26. Captured in Glossary and §4.1.
**Implementation Notes:**
- Priority chain: custom fallback portrait -> user.avatar -> user.character?.img -> mystery-man
- Integrated with RoleRenderer for participant list building
- Handled in buildParticipantList function
### D-21 — CSS Build Pipeline
**Date:** 2026-05-24
**Decision:** Add postinstall script to automatically build CSS from LESS source files.
**Rationale:** Streamlines development and distribution; ensures styles are always up-to-date.
**Status:** Implementation note in §6.1 MVP Scope.
**Implementation Notes:**
- Added postinstall script to package.json
- Moved styles to root styles/ folder for FoundryVTT compatibility
- Updated .gitignore to allow dist/styles/ for CSS distribution
---
## Decision Summary Table
| Decision | Date | Status | Related FRs | Section |
|----------|------|--------|-------------|---------|
| D-1 | 2026-05-19 | Active | FR-1-32 | §1, §6, §10 |
| D-2 | 2026-05-19 | Active | FR-7 | §4.1, §9 |
| D-3 | 2026-05-19 | Active | All | §5, §6 |
| D-4 | 2026-05-19 | Active | All | §7, Cross-cutting |
| D-5 | 2026-05-19 | Active | FR-1-26 | §4 |
| D-6 | 2026-05-19 | Active | FR-10 | §4.2 |
| D-7 | 2026-05-19 | Active | FR-24 | §4.5 |
| D-8 | 2026-05-19 | Active | All | §6.1 |
| D-9 | 2026-05-19 | Active | FR-2 | §4.1, §9 |
| D-10 | 2026-05-19 | Active | FR-7 | §4.1, §8 |
| D-11 | 2026-05-19 | Active | FR-6 | §4.1, §9 |
| D-12 | 2026-05-19 | Active | All | §5 |
| D-13 | 2026-05-19 | Active | FR-24, FR-25 | §4.5 |
| D-14 | 2026-05-19 | Active | FR-9 | §4.2 |
| D-15 | 2026-05-19 | Active | All automation | Cross-cutting |
| **D-16** | **2026-05-20** | **Active** | **FR-27, FR-28** | **§4.6** |
| **D-17** | **2026-05-22** | **Active** | **FR-29, FR-30** | **§4.7** |
| **D-18** | **2026-05-24** | **Active** | **FR-31** | **§4.8** |
| **D-19** | **2026-05-20** | **Active** | **FR-32** | **§4.9** |
| **D-20** | **2026-05-23** | **Active** | **FR-8, FR-26** | **§4.1, §4.5** |
| **D-21** | **2026-05-24** | **Active** | **N/A** | **§6.1** |
---
## Open Questions Status
| OQ | Phase Blocker? | Status | Related Decisions |
|---|---|---|---|
| OQ-1: WebRTC track disable API access | No | Open — to be checked against v14 API during development; CSS fallback is safe default | D-2, D-10 |
| OQ-2: socketlib vs native socket API | No | **RESOLVED** — native FoundryVTT socket API | D-9 |
| OQ-3: GM own-feed hidden behavior | No | **RESOLVED** — GM always sees all activated feeds; own self-view is configurable | D-11 |
| OQ-4: Non-native AV backend support | No | **RESOLVED** — native WebRTC only for v1.0 | D-12 |
| OQ-5: Scene hook timing | No | Open — not a concern for first implementation stage | |
| OQ-6: Partial vs full preset application | No | Open — to be resolved during FR-15/FR-16 implementation | |
| **OQ-7: Full AV replacement edge cases** | **No** | **Open** — Does full AV dock replacement handle all edge cases? | **D-16** |
---
## PRD Version History
| Version | Date | Status | Author | Changes |
|---------|------|--------|--------|---------|
| 1.0 | 2026-05-19 | final | Morr | Initial PRD with FR-1 through FR-26 |
| **1.1** | **2026-05-25** | **draft** | **Morr** | **Added FR-27 through FR-32 from live testing; updated D-1, added D-16 through D-21** |
@@ -1,17 +1,20 @@
--- ---
title: "Video View Manager — FoundryVTT Webcam Visibility Control Module" title: "Video View Manager — FoundryVTT Webcam Visibility Control Module"
status: final status: draft
created: 2026-05-19 created: 2026-05-19
updated: 2026-05-19 updated: 2026-05-25
version: 0.1.0
--- ---
# PRD: Video View Manager # PRD: Video View Manager
## 0. Document Purpose ## 0. Document Purpose
This PRD is the authoritative specification for **Video View Manager**, a FoundryVTT v14 module. It is written for the module author (Morr) and future contributors, and it is the source of record for scope decisions during development. This PRD is the authoritative specification for **Video View Manager** (module ID: `scrying-pool`), a FoundryVTT v14 module. It is written for the module author (Morr) and future contributors, and it is the source of record for scope decisions during development.
This document uses Glossary terms from §3 throughout §4+ and keeps functional requirements globally numbered FR-1 through FR-26 for stable reference. Inline `[ASSUMPTION]` tags in §4 mark unconfirmed inferences and are indexed in §9. The primary upstream input is `_bmad-output/brainstorming/brainstorming-session-2026-05-19-221747.md`. This document uses Glossary terms from §3 throughout §4+ and keeps functional requirements globally numbered FR-1 through FR-32 for stable reference. Inline `[ASSUMPTION]` tags in §4 mark unconfirmed inferences and are indexed in §9. The primary upstream input is `_bmad-output/brainstorming/brainstorming-session-2026-05-19-221747.md`.
**Implementation Updates:** This version incorporates enhancements from live testing completed May 25, 2026, including full AV dock replacement, configurable dock layouts, and position persistence.
--- ---
@@ -23,6 +26,12 @@ From that core, the module extends into session cinematography. Scene Presets le
The module follows the **Progressive Enhancement Architecture**. Level 1 adds a right-click action to existing AV Tiles. Level 2 adds the Director's Board. Level 3 adds Scene Presets and automation. Participants stay informed, automation that changes an individual's on-screen presence requires explicit opt-in, and the GM always has an immediate override. The module follows the **Progressive Enhancement Architecture**. Level 1 adds a right-click action to existing AV Tiles. Level 2 adds the Director's Board. Level 3 adds Scene Presets and automation. Participants stay informed, automation that changes an individual's on-screen presence requires explicit opt-in, and the GM always has an immediate override.
**Live Test Enhancements (May 2026):**
- Full replacement of Foundry's native AV dock with custom ScryingPoolStrip
- Configurable dock layouts (vertical, horizontal, mosaic) with size variants
- Position persistence across sessions
- Enhanced portrait fallback handling
--- ---
## 2. Target Users ## 2. Target Users
@@ -48,6 +57,8 @@ Alex has just joined their first online TTRPG. They are comfortable with Zoom bu
- Spotlight one Participant for a dramatic reveal or emotional scene climax - Spotlight one Participant for a dramatic reveal or emotional scene climax
- Apply a Scene Preset when transitioning between scenes without manual intervention - Apply a Scene Preset when transitioning between scenes without manual intervention
- Know at a glance which of 6+ Participants are hidden, visible, or offline - Know at a glance which of 6+ Participants are hidden, visible, or offline
- Configure dock layout to match table preferences (vertical, horizontal, mosaic)
- Maintain dock position and size preferences across sessions
**Social / Emotional** **Social / Emotional**
- Feel in control of the table atmosphere as a GM — the visual equivalent of adjusting the lights - Feel in control of the table atmosphere as a GM — the visual equivalent of adjusting the lights
@@ -60,37 +71,6 @@ Alex has just joined their first online TTRPG. They are comfortable with Zoom bu
- Groups using FoundryVTT v13 or earlier - Groups using FoundryVTT v13 or earlier
- Streamers requiring independent audience layouts (served in the later roadmap — §10) - Streamers requiring independent audience layouts (served in the later roadmap — §10)
### 2.4 Key User Journeys
**UJ-1. Marcus transitions into a boss fight and spotlights the villain's lair.**
- **Persona + context:** Marcus, mid-session, activating a Scene named "Throne Room" where he wants only two Participants visible.
- **Entry state:** A Scene Preset is configured at prep time; the Director's Board is closed.
- **Path:** (1) Marcus activates the "Throne Room" scene. (2) The module auto-applies the saved preset — two Participants are visible and four are hidden. (3) A toast appears for all Participants: "Scene changed: camera layout updated." (4) Marcus glances at the AV Tile strip — hidden tiles show a grey overlay with a lock icon.
- **Climax:** The table's visual focus narrows to the two Participants in the scene without Marcus touching a control.
- **Resolution:** Marcus keeps narrating; he can override the preset with one right-click if needed.
- **Edge case:** If a Participant whose feed should be visible is offline, the hidden overlay still appears and the preset is marked partially applied.
**UJ-2. Sofia opts out of HP-Reactive Cam Styling before the session.**
- **Persona + context:** Sofia, before the session starts, opening her FoundryVTT user settings.
- **Entry state:** Authenticated as a Participant; the Player Privacy Panel is available in module settings; HP-Reactive Cam Styling is currently ON from an earlier opt-in.
- **Path:** (1) Sofia opens the Player Privacy Panel. (2) She sees HP-Reactive Cam Styling: ON. (3) She toggles it OFF. (4) The setting saves instantly; no page reload is required.
- **Climax:** Sofia knows her camera will not change appearance based on her HP.
- **Resolution:** Her opt-out flag persists for future sessions in this world until she changes it.
**UJ-3. Jake opens the Director's Board at session start to configure the layout.**
- **Persona + context:** Jake, 5 minutes before going live, with 5 Participants connected.
- **Entry state:** Authenticated as GM; the Director's Board has not been opened this session.
- **Path:** (1) Jake presses the keyboard shortcut (⌘/Ctrl+Shift+V). (2) The Director's Board opens as a floating window. (3) Jake sees all 5 Participants in a seating-chart layout with their current states. (4) He sets Participant 4 to "Hidden" (late joiner, not on yet). (5) He uses Spotlight on his own card to confirm his feed remains visible.
- **Climax:** Jake's table is visually configured for broadcast before he goes live.
- **Resolution:** The Director's Board stays open in a corner, and Jake uses it as a live monitor during the session.
**UJ-4. Alex sees a toast notification when their feed is hidden.**
- **Persona + context:** Alex, playing for the first time; Marcus has just hidden Alex's camera to manage a technical glitch.
- **Entry state:** Alex's AV Tile is live; Marcus triggers hide from the context menu.
- **Path:** (1) Alex's tile changes to Portrait Fallback mode with a grey overlay. (2) Alex receives a toast: "GM has hidden your camera. Your portrait is shown to other Participants." (3) Alex sees a small "Camera hidden" badge on their own tile.
- **Climax:** Alex understands exactly what happened and why — no confusion or anxiety.
- **Resolution:** Alex can keep playing; their audio is unaffected.
--- ---
## 3. Glossary ## 3. Glossary
@@ -100,26 +80,31 @@ Alex has just joined their first online TTRPG. They are comfortable with Zoom bu
- **Combat Cinematics Mode** — An automation mode that manages the Visibility Matrix during active FoundryVTT combat, spotlighting the active combatant's feed. Part of the Later roadmap. - **Combat Cinematics Mode** — An automation mode that manages the Visibility Matrix during active FoundryVTT combat, spotlighting the active combatant's feed. Part of the Later roadmap.
- **Director's Board** — The floating window for bulk Visibility Matrix management. Primary UI surface for Level 2/3 interactions. Synonyms are not permitted — this is not "the settings panel" or "the popout." - **Director's Board** — The floating window for bulk Visibility Matrix management. Primary UI surface for Level 2/3 interactions. Synonyms are not permitted — this is not "the settings panel" or "the popout."
- **Director's Board Stage Lighting States** — A Later-roadmap preset vocabulary tier for cinematic bulk actions such as Wash, Focus, and Blackout. - **Director's Board Stage Lighting States** — A Later-roadmap preset vocabulary tier for cinematic bulk actions such as Wash, Focus, and Blackout.
- **Dock Layout** — Configurable layout options for the AV dock strip: `vertical-sm`, `vertical-md`, `horizontal-sm`, `horizontal-md`, `mosaic-sm`, `mosaic-md`. Controlled via world setting `dockLayout` with client-scoped override via `dockLayoutExpanded`.
- **Dual Layout System** — A Later-roadmap architecture that separates the Participant-facing layout from the Spectator View. - **Dual Layout System** — A Later-roadmap architecture that separates the Participant-facing layout from the Spectator View.
- **Full AV Replacement** — Complete replacement of FoundryVTT's native AV dock with custom ScryingPoolStrip and RoleRenderer components, providing full control over participant feed display.
- **GM** — Game Master. The FoundryVTT user with the `GAMEMASTER` role. Has exclusive authority over the Visibility Matrix unless Player Permissions are extended (future roadmap). - **GM** — Game Master. The FoundryVTT user with the `GAMEMASTER` role. Has exclusive authority over the Visibility Matrix unless Player Permissions are extended (future roadmap).
- **HP-Reactive Cam Styling** — An opt-in automation effect that changes the presentation of a Participant's feed based on HP-related game events. Part of the Later roadmap. - **HP-Reactive Cam Styling** — An opt-in automation effect that changes the presentation of a Participant's feed based on HP-related game events. Part of the Later roadmap.
- **NPC Presence Tiles** — A Later-roadmap feature that displays static image tiles for NPC voice actors or other non-camera presences. - **NPC Presence Tiles** — A Later-roadmap feature that displays static image tiles for NPC voice actors or other non-camera presences.
- **Participant** — Any connected FoundryVTT user with an AV presence (camera, microphone, or both), including the GM. - **Participant** — Any connected FoundryVTT user with an AV presence (camera, microphone, or both), including the GM.
- **Participant State** — One of eight enumerated states describing a Participant's AV presence: `active`, `hidden`, `self-muted`, `offline`, `cam-lost`, `reconnecting`, `never-connected`, `ghost`. Defined in §4.1. - **Participant State** — One of eight enumerated states describing a Participant's AV presence: `active`, `hidden`, `self-muted`, `offline`, `cam-lost`, `reconnecting`, `never-connected`, `ghost`. Defined in §4.1.
- **Player Privacy Panel** — The per-user settings interface for opting in or out of cinematic automation effects. Scoped to the current user; accessible from module settings. - **Player Privacy Panel** — The per-user settings interface for opting in or out of cinematic automation effects. Scoped to the current user; accessible from module settings.
- **Portrait Fallback** — A static image (user avatar or actor portrait) displayed in place of a live camera feed when a Participant has no camera, or when their feed is `cam-lost`. - **Portrait Fallback** — A static image (user avatar or actor portrait) displayed in place of a live camera feed when a Participant has no camera, or when their feed is `cam-lost`. Enhanced with custom portrait handler integration via `PortraitFallbackHandler`.
- **PortraitFallbackHandler** — Component that manages custom portrait fallback images for participants, with priority: custom fallback → user avatar → character portrait → mystery-man placeholder.
- **Progressive Enhancement Architecture** — The three-level UI model: Level 1 (right-click on existing AV Tiles — zero new UI), Level 2 (Director's Board), Level 3 (Scene Presets + full automation). Each level is independently useful. - **Progressive Enhancement Architecture** — The three-level UI model: Level 1 (right-click on existing AV Tiles — zero new UI), Level 2 (Director's Board), Level 3 (Scene Presets + full automation). Each level is independently useful.
- **Pull Visibility Model** — A Later-roadmap visibility model in which feeds remain hidden until a Viewer explicitly requests them. - **Pull Visibility Model** — A Later-roadmap visibility model in which feeds remain hidden until a Viewer explicitly requests them.
- **Reaction Cam** — An opt-in feature that automatically spotlights a Participant's feed during key game moments (for example, taking damage in combat). Part of the Later roadmap. - **Reaction Cam** — An opt-in feature that automatically spotlights a Participant's feed during key game moments (for example, taking damage in combat). Part of the Later roadmap.
- **Reaction Clip System** — A Later-roadmap fallback that shows a short video snippet when a Participant has no live camera feed. - **Reaction Clip System** — A Later-roadmap fallback that shows a short video snippet when a Participant has no live camera feed.
- **RoleRenderer** — Custom component that replaces Foundry's native AV tile rendering, integrating with ScryingPoolStrip and PortraitFallbackHandler for consistent participant display.
- **Scene Preset** — A saved snapshot of the Visibility Matrix, optionally linked to a FoundryVTT Scene for automatic application on scene activation. - **Scene Preset** — A saved snapshot of the Visibility Matrix, optionally linked to a FoundryVTT Scene for automatic application on scene activation.
- **ScryingPoolStrip** — The custom AV dock strip component that replaces Foundry's native AV dock, implementing configurable layouts, position persistence, and integrated participant feed management.
- **Spectator View** — A read-only camera layout independent from the Participant layout, intended for streaming audiences. Part of the Later roadmap. - **Spectator View** — A read-only camera layout independent from the Participant layout, intended for streaming audiences. Part of the Later roadmap.
- **The Living Table** — A Later-roadmap concept that exposes the full seating-chart UI for `Map<participantId, Map<viewerId, VisibilityState>>` relationships. - **The Living Table** — A Later-roadmap concept that exposes the full seating-chart UI for `Map<participantId, Map<viewerId, VisibilityState>>` relationships.
- **Token-Anchored Floating Cams** — A Later-roadmap feature that links camera surfaces to canvas tokens. - **Token-Anchored Floating Cams** — A Later-roadmap feature that links camera surfaces to canvas tokens.
- **Visibility Matrix** — The authoritative data structure representing all camera visibility relationships: `Map<participantId, Map<viewerId, VisibilityState>>`. Stored in world-level settings and broadcast to all clients on change. - **Visibility Matrix** — The authoritative data structure representing all camera visibility relationships: `Map<participantId, Map<viewerId, VisibilityState>>`. Stored in world-level settings and broadcast to all clients on change.
- **Zero-UI Full Automation Mode** — A Later-roadmap mode that minimizes manual camera control after initial configuration.
- **Visibility State** — The visibility setting for one Participant's camera as seen by one Viewer. Distinct from Participant State: a feed can be `active` but have a `hidden` Visibility State for specific viewers. - **Visibility State** — The visibility setting for one Participant's camera as seen by one Viewer. Distinct from Participant State: a feed can be `active` but have a `hidden` Visibility State for specific viewers.
- **Viewer** — A Participant who is receiving (watching) another Participant's camera feed. - **Viewer** — A Participant who is receiving (watching) another Participant's camera feed.
- **Zero-UI Full Automation Mode** — A Later-roadmap mode that minimizes manual camera control after initial configuration.
--- ---
@@ -189,7 +174,7 @@ All Participant States produce appropriate visual feedback and do not cause AV T
**Consequences (testable):** **Consequences (testable):**
- Hiding or revealing a Participant's feed does not change the position of other AV Tiles. - Hiding or revealing a Participant's feed does not change the position of other AV Tiles.
- Mid-combat state transitions (`active` `offline` `reconnecting`) do not shift the combat tracker or map canvas layout. - Mid-combat state transitions (`active` -> `offline` -> `reconnecting`) do not shift the combat tracker or map canvas layout.
- A Participant in `ghost` state has no AV Tile rendered for other Participants. - A Participant in `ghost` state has no AV Tile rendered for other Participants.
#### FR-6: GM sees all activated Participant feeds; GM self-view is configurable #### FR-6: GM sees all activated Participant feeds; GM self-view is configurable
@@ -218,6 +203,7 @@ When a Participant has no camera device (`never-connected`) or enters `cam-lost`
- The default Portrait Fallback uses the FoundryVTT user avatar and falls back to a system placeholder if no avatar is set. - The default Portrait Fallback uses the FoundryVTT user avatar and falls back to a system placeholder if no avatar is set.
- Participants can set a custom Portrait Fallback through the Player Privacy Panel (FR-26). - Participants can set a custom Portrait Fallback through the Player Privacy Panel (FR-26).
- Portrait Fallback renders at the same dimensions as a live camera-feed tile. - Portrait Fallback renders at the same dimensions as a live camera-feed tile.
- Custom PortraitFallbackHandler integrates with priority: custom fallback portrait -> user.avatar -> user.character?.img -> mystery-man placeholder.
**Feature-specific NFRs:** **Feature-specific NFRs:**
- Visibility Matrix updates must not block the FoundryVTT rendering loop; state changes apply asynchronously. - Visibility Matrix updates must not block the FoundryVTT rendering loop; state changes apply asynchronously.
@@ -235,7 +221,7 @@ When a Participant has no camera device (`never-connected`) or enters `cam-lost`
A dedicated button in the FoundryVTT controls sidebar opens the Director's Board. A keyboard shortcut (default: `Ctrl+Shift+V`) also opens it. Realizes UJ-3. A dedicated button in the FoundryVTT controls sidebar opens the Director's Board. A keyboard shortcut (default: `Ctrl+Shift+V`) also opens it. Realizes UJ-3.
**Consequences (testable):** **Consequences (testable):**
- The Director's Board opens as a resizable, draggable `ApplicationV2` window [ASSUMPTION]. - The Director's Board opens as a resizable, draggable ApplicationV2 window [ASSUMPTION].
- Opening the Director's Board does not change the existing AV Tile strip. - Opening the Director's Board does not change the existing AV Tile strip.
- The keyboard shortcut is configurable in module settings. - The keyboard shortcut is configurable in module settings.
- The window closes and reopens instantly, supporting both a pre-session setup workflow and a live-monitor workflow. - The window closes and reopens instantly, supporting both a pre-session setup workflow and a live-monitor workflow.
@@ -247,6 +233,7 @@ All connected Participants are shown with their current Visibility State, name,
- Every Participant card displays the name, portrait, current Participant State, and current Visibility State. - Every Participant card displays the name, portrait, current Participant State, and current Visibility State.
- The layout reads as a seating chart, not a list. - The layout reads as a seating chart, not a list.
- Updates to Visibility State appear in the Director's Board within 500 ms, matching FR-2. - Updates to Visibility State appear in the Director's Board within 500 ms, matching FR-2.
- Dock layout selector is available in Director's Board for GM to configure world-level layout preference.
#### FR-11: Per-Participant visibility toggle from Director's Board #### FR-11: Per-Participant visibility toggle from Director's Board
The GM can toggle any single Participant's Visibility State from that Participant's card in the Director's Board. The GM can toggle any single Participant's Visibility State from that Participant's card in the Director's Board.
@@ -311,7 +298,7 @@ A preset can be linked to a Scene; it applies automatically when that Scene is a
**Consequences (testable):** **Consequences (testable):**
- The Scene-to-Preset association is configured in Scene settings or module settings. - The Scene-to-Preset association is configured in Scene settings or module settings.
- Auto-apply fires on the `updateScene` hook, and a configurable 05000 ms pre-delay supports dramatic transitions [ASSUMPTION: sufficient hook timing — to be verified during FR-17 development]. - Auto-apply fires on the `updateScene` hook, and a configurable 0-5000 ms pre-delay supports dramatic transitions [ASSUMPTION: sufficient hook timing — to be verified during FR-17 development].
- All clients receive the notification "Scene changed: camera layout updated." matching UJ-1. - All clients receive the notification "Scene changed: camera layout updated." matching UJ-1.
#### FR-18: Scene Preset auto-apply can be disabled per scene or globally #### FR-18: Scene Preset auto-apply can be disabled per scene or globally
@@ -401,6 +388,92 @@ A Participant can set a custom image as their Portrait Fallback (used in FR-8 wh
- A file picker in the Player Privacy Panel sets the custom portrait. - A file picker in the Player Privacy Panel sets the custom portrait.
- Accepted formats are PNG, JPG, WEBP, and static GIF. - Accepted formats are PNG, JPG, WEBP, and static GIF.
- The image falls back to the FoundryVTT user avatar if no custom portrait is set, then to the system placeholder if no avatar exists. - The image falls back to the FoundryVTT user avatar if no custom portrait is set, then to the system placeholder if no avatar exists.
- Custom portraits are handled by PortraitFallbackHandler with proper priority chain.
---
### 4.6 Full AV Dock Replacement
**Description:** Complete replacement of FoundryVTT's native AV dock with custom implementation via ScryingPoolStrip and RoleRenderer. This provides full control over participant feed rendering, layout, and display.
**Functional Requirements:**
#### FR-27: Custom AV dock strip (ScryingPoolStrip)
The module replaces Foundry's native AV dock with ScryingPoolStrip, a custom ApplicationV2-based component.
**Consequences (testable):**
- ScryingPoolStrip opens automatically for all users (GM and players) when AV is active.
- The strip is implemented using FoundryVTT v14 ApplicationV2 API with HandlebarsApplicationMixin.
- Custom templates (roster-strip.hbs) render participant tiles with configurable layout.
- Strip integrates with PortraitFallbackHandler for consistent portrait display.
#### FR-28: Custom role rendering (RoleRenderer)
RoleRenderer component handles individual participant feed rendering and management.
**Consequences (testable):**
- RoleRenderer coordinates between StateStore, ScryingPoolController, AVTileAdapter, and PortraitFallbackHandler.
- Participant list is built with proper priority for fallback portraits (custom -> user avatar -> character portrait -> mystery-man).
- RoleRenderer manages strip lifecycle (open, close, rerender).
---
### 4.7 Dock Layout System
**Description:** Configurable dock layout options for the AV dock strip, allowing GMs and users to customize how participant tiles are arranged.
**Functional Requirements:**
#### FR-29: Dock layout configuration
The module provides 6 layout options via world-scoped `dockLayout` setting.
**Consequences (testable):**
- Available layouts: `vertical-sm`, `vertical-md`, `horizontal-sm`, `horizontal-md`, `mosaic-sm`, `mosaic-md`.
- Layout is configured via Director's Board UI with visual selector.
- Changing layout re-renders the strip for all users via onChange callback.
- Default layout is `vertical-sm`.
#### FR-30: Per-user layout size override
Users can override the GM's layout size preference via client-scoped `dockLayoutExpanded` setting.
**Consequences (testable):**
- Setting values: `''` (inherit from world), `'sm'` (force small), `'md'` (force large).
- Client setting only affects size, not layout direction (vertical/horizontal/mosaic).
- Changing client override re-renders the strip for that user only.
- Legacy boolean values are migrated to string format automatically.
---
### 4.8 Position Persistence
**Description:** The ScryingPoolStrip remembers its window position across sessions for consistent user experience.
**Functional Requirements:**
#### FR-31: Window position persistence
ScryingPoolStrip position is saved and restored from GM user flags.
**Consequences (testable):**
- Position (left, top coordinates) is saved when strip is moved.
- Position is loaded on module initialization via `_loadPosition()` method.
- Saved position applies only to the GM user; player strip positions follow GM configuration.
- First-time users get default position (left: 0, top: 0) or system default.
---
### 4.9 Implementation Quality
**Description:** Code quality improvements and FoundryVTT v14 compatibility.
**Functional Requirements:**
#### FR-32: ApplicationV2 API compatibility
All UI components properly extend and use FoundryVTT v14 ApplicationV2 API.
**Consequences (testable):**
- ScryingPoolStrip extends HandlebarsApplicationMixin(ApplicationV2).
- DirectorsBoard extends HandlebarsApplicationMixin(ApplicationV2).
- All _onRender, _prepareContext, and _onClose methods properly implemented.
- jQuery parameter handling is compatible with ApplicationV2 expectations.
--- ---
@@ -422,13 +495,18 @@ A Participant can set a custom image as their Portrait Fallback (used in FR-8 wh
### 6.1 In Scope (v1.0) ### 6.1 In Scope (v1.0)
- Core Visibility Toggle (FR-1 FR-8): right-click toggle, broadcast, persistence, visual indicators, 8 Participant States, WebRTC track disabling with CSS fallback, Portrait Fallback - Core Visibility Toggle (FR-1 - FR-8): right-click toggle, broadcast, persistence, visual indicators, 8 Participant States, WebRTC track disabling with CSS fallback, Portrait Fallback
- Director's Board (FR-9 FR-14): seating-chart window, bulk actions, Spotlight, keyboard shortcuts - Director's Board (FR-9 - FR-14): seating-chart window, bulk actions, Spotlight, keyboard shortcuts
- Scene-Aware Visibility Presets (FR-15 FR-19): save, load, auto-apply, and JSON import/export - Scene-Aware Visibility Presets (FR-15 - FR-19): save, load, auto-apply, and JSON import/export
- Contextual Notifications (FR-20 FR-22): toast system, verbosity configuration, persistent self-status badge - Contextual Notifications (FR-20 - FR-22): toast system, verbosity configuration, persistent self-status badge
- Player Privacy Panel (FR-23 FR-26): opt-in controls for future automation effects, custom portrait - Player Privacy Panel (FR-23 - FR-26): opt-in controls for future automation effects, custom portrait
- **Full AV Dock Replacement (FR-27 - FR-28)**: ScryingPoolStrip and RoleRenderer components
- **Dock Layout System (FR-29 - FR-30)**: 6 layout options with per-user override
- **Position Persistence (FR-31)**: Window position saved and restored
- **ApplicationV2 Compatibility (FR-32)**: Full v14 API compliance
- FoundryVTT v14+ compatibility; `module.json` per v14 manifest schema - FoundryVTT v14+ compatibility; `module.json` per v14 manifest schema
- English UI strings; i18n-ready string keys for community translation - English UI strings; i18n-ready string keys for community translation
- CSS build pipeline with postinstall script for automatic CSS compilation
### 6.2 Out of Scope for MVP ### 6.2 Out of Scope for MVP
@@ -473,6 +551,7 @@ A Participant can set a custom image as their Portrait Fallback (used in FR-8 wh
4. ~~**OQ-4:** Should the module operate with Jitsi Meeting Server and other non-native AV backends (beyond native WebRTC), or is native WebRTC the only supported backend for v1.0?~~ **RESOLVED:** Native WebRTC only for v1.0. Non-native backends (Jitsi, etc.) are deferred to the Later roadmap. 4. ~~**OQ-4:** Should the module operate with Jitsi Meeting Server and other non-native AV backends (beyond native WebRTC), or is native WebRTC the only supported backend for v1.0?~~ **RESOLVED:** Native WebRTC only for v1.0. Non-native backends (Jitsi, etc.) are deferred to the Later roadmap.
5. **OQ-5:** Do FoundryVTT v14 scene activation hooks fire early enough for Scene Preset auto-apply (FR-17) to avoid a visible flash of the wrong camera layout before the preset applies? Not a concern for the first implementation stage — defer to testing. 5. **OQ-5:** Do FoundryVTT v14 scene activation hooks fire early enough for Scene Preset auto-apply (FR-17) to avoid a visible flash of the wrong camera layout before the preset applies? Not a concern for the first implementation stage — defer to testing.
6. **OQ-6:** Should Scene Presets support partial application — applying only to currently connected Participants and deferring state for offline ones — or should they always apply to all participant slots unconditionally? This cannot be decided yet and will be resolved during FR-15/FR-16 implementation. 6. **OQ-6:** Should Scene Presets support partial application — applying only to currently connected Participants and deferring state for offline ones — or should they always apply to all participant slots unconditionally? This cannot be decided yet and will be resolved during FR-15/FR-16 implementation.
7. **OQ-7 (NEW):** Does the full AV dock replacement (FR-27-28) properly handle all edge cases of Foundry's native AV system, including dynamic participant join/leave, device changes, and AV mode toggles?
--- ---
@@ -483,6 +562,9 @@ A Participant can set a custom image as their Portrait Fallback (used in FR-8 wh
- **§4.2 / FR-9:** FoundryVTT v14's `ApplicationV2` API is the correct pattern for a resizable floating window with persistent state. - **§4.2 / FR-9:** FoundryVTT v14's `ApplicationV2` API is the correct pattern for a resizable floating window with persistent state.
- **§4.3 / FR-15:** 50 presets per world is an adequate upper bound for any campaign use case. If communities surface larger preset libraries, this can increase. - **§4.3 / FR-15:** 50 presets per world is an adequate upper bound for any campaign use case. If communities surface larger preset libraries, this can increase.
- **§4.3 / FR-17:** The `updateScene` hook (or its v14 equivalent) fires early enough in the scene-transition lifecycle to apply the preset before the AV Tile strip re-renders. - **§4.3 / FR-17:** The `updateScene` hook (or its v14 equivalent) fires early enough in the scene-transition lifecycle to apply the preset before the AV Tile strip re-renders.
- **§4.6 / FR-27:** ScryingPoolStrip as ApplicationV2-based component can fully replace Foundry's native AV dock without breaking core functionality.
- **§4.7 / FR-29:** 6 dock layout options (vertical-sm/md, horizontal-sm/md, mosaic-sm/md) cover the majority of table configuration needs.
- **§4.8 / FR-31:** GM user flags are the appropriate storage mechanism for strip position persistence.
--- ---
@@ -512,23 +594,28 @@ The following concepts from the brainstorming session are architecturally consis
**Compatibility** **Compatibility**
- The module must not conflict with other popular FoundryVTT modules (Monk's Hotbar, Token Action HUD, etc.) by patching shared DOM selectors or overriding core FoundryVTT hooks without proper chaining. - The module must not conflict with other popular FoundryVTT modules (Monk's Hotbar, Token Action HUD, etc.) by patching shared DOM selectors or overriding core FoundryVTT hooks without proper chaining.
- All hooks must use FoundryVTT's `Hooks.on()` registration pattern, never `Hooks.once()` for persistent behavior. - All hooks must use FoundryVTT's `Hooks.on()` registration pattern, never `Hooks.once()` for persistent behavior.
- **NEW:** Full AV replacement must coexist with other modules that patch AV-related hooks; proper hook chaining is required.
**Performance** **Performance**
- No Visibility Matrix operation should block the FoundryVTT main render loop. - No Visibility Matrix operation should block the FoundryVTT main render loop.
- The Director's Board must render and become interactive within 1 second even with 12 Participants. - The Director's Board must render and become interactive within 1 second even with 12 Participants.
- Socket message payload for a Visibility Matrix update must be ≤ 4 KB. - Socket message payload for a Visibility Matrix update must be ≤ 4 KB.
- **NEW:** Strip rendering with 6+ participants must maintain 60fps in all layout modes.
**Reliability** **Reliability**
- If the socket broadcast fails because of a network interruption, the GM client retries up to 3 times before surfacing an error notification. - If the socket broadcast fails because of a network interruption, the GM client retries up to 3 times before surfacing an error notification.
- The module must fail gracefully if `game.webrtc` is unavailable (for example, when AV is disabled); all UI elements are hidden or disabled rather than erroring. - The module must fail gracefully if `game.webrtc` is unavailable (for example, when AV is disabled); all UI elements are hidden or disabled rather than erroring.
- **NEW:** Position persistence must handle corrupted or invalid saved positions gracefully.
**Privacy** **Privacy**
- The module does not transmit any data outside the FoundryVTT world. No analytics, telemetry, or third-party calls. - The module does not transmit any data outside the FoundryVTT world. No analytics, telemetry, or third-party calls.
- Participant names and portraits are used only within the FoundryVTT session; no external storage is allowed. - Participant names and portraits are used only within the FoundryVTT session; no external storage is allowed.
- **NEW:** Custom portrait images are stored in FoundryVTT's standard file storage; no external uploads.
**Accessibility** **Accessibility**
- All interactive elements in the Director's Board have ARIA labels and are keyboard navigable (FR-14). - All interactive elements in the Director's Board have ARIA labels and are keyboard navigable (FR-14).
- State-indicator icons (FR-4) include tooltip text for screen-reader compatibility. - State-indicator icons (FR-4) include tooltip text for screen-reader compatibility.
- **NEW:** All dock layout options are keyboard-navigable and have proper ARIA labels.
**Language and Voice** **Language and Voice**
- Default UI labels use plain language: "Show," "Hide," "Spotlight," "Hidden by GM," "No Camera." Technical or cinematic vocabulary (for example, "Visibility Matrix" or "Wash / Focus / Blackout") is reserved for documentation, tooltips in advanced mode, and developer-facing strings — never as primary interface labels. - Default UI labels use plain language: "Show," "Hide," "Spotlight," "Hidden by GM," "No Camera." Technical or cinematic vocabulary (for example, "Visibility Matrix" or "Wash / Focus / Blackout") is reserved for documentation, tooltips in advanced mode, and developer-facing strings — never as primary interface labels.
@@ -540,4 +627,6 @@ The following concepts from the brainstorming session are architecturally consis
- This is a non-negotiable cross-cutting rule: no automation may be implemented if it cannot be interrupted or overridden immediately by the GM. - This is a non-negotiable cross-cutting rule: no automation may be implemented if it cannot be interrupted or overridden immediately by the GM.
**Delivery Risk Note (v1.0 scope)** **Delivery Risk Note (v1.0 scope)**
- v1.0 intentionally combines the Day 1 core toggle (FR-1FR-8) with Week 12 enhancements (FR-9FR-26) into a single release. The brainstorming established Day 1 as a shippable standalone product; if delivery constraints arise, the Level 1 core toggle (FR-1FR-8) is the minimum shippable increment, and all higher-level features can shift to v1.1 without breaking the architecture. - v1.0 intentionally combines the Day 1 core toggle (FR-1-8) with Week 1-2 enhancements (FR-9-26) and live-test enhancements (FR-27-32) into a single release. The brainstorming established Day 1 as a shippable standalone product; if delivery constraints arise, the Level 1 core toggle (FR-1-8) is the minimum shippable increment, and all higher-level features can shift to v1.1 without breaking the architecture.
---
+6 -3
View File
@@ -106,10 +106,11 @@ export class PresetImportExportManager {
* @returns {string} Generated filename. * @returns {string} Generated filename.
*/ */
generateExportFilename(worldName, includeTimestamp = true) { generateExportFilename(worldName, includeTimestamp = true) {
const name = worldName ?? this._adapter.scenes.current?.()?.name ?? 'world'; const currentScene = this._adapter.scenes.current?.();
const name = worldName ?? currentScene?.parent?.name ?? currentScene?.name ?? 'world';
// Sanitize name: replace any non-alphanumeric, dash, or underscore with underscore // Sanitize name: replace any non-alphanumeric, dash, or underscore with underscore
// Also handle empty string by using 'world' as fallback // Also handle empty string by using 'world' as fallback
const safeName = name.replace(/[^a-zA-Z0-9-_]/g, '_').toLowerCase() || 'world'; const safeName = name.replace(/[^a-zA-Z0-9\-_]/g, '_').toLowerCase() || 'world';
const timestamp = includeTimestamp ? `_${Date.now()}` : ''; const timestamp = includeTimestamp ? `_${Date.now()}` : '';
return `scrying-pool-presets-${safeName}${timestamp}.json`; return `scrying-pool-presets-${safeName}${timestamp}.json`;
} }
@@ -235,7 +236,7 @@ export class PresetImportExportManager {
} }
// Validate preset name characters (alphanumeric, dash, underscore, space, dot) // Validate preset name characters (alphanumeric, dash, underscore, space, dot)
if (!/^[a-zA-Z0-9\s._-]+$/.test(name)) { if (!/^[a-zA-Z0-9 ._-]+$/.test(name)) {
results.push({ name, preset: null, error: `Preset "${name}": name contains invalid characters (only alphanumeric, space, dot, dash, underscore allowed)` }); results.push({ name, preset: null, error: `Preset "${name}": name contains invalid characters (only alphanumeric, space, dot, dash, underscore allowed)` });
continue; continue;
} }
@@ -339,11 +340,13 @@ export class PresetImportExportManager {
const result = await this._replacePresets(data, validPresets, existingCount); const result = await this._replacePresets(data, validPresets, existingCount);
// Merge extraction errors with replace errors // Merge extraction errors with replace errors
result.errors = [...errors, ...result.errors]; result.errors = [...errors, ...result.errors];
result.success = result.errors.length === 0;
return result; return result;
} }
const result = await this._mergePresets(data, validPresets, existingPresetNames); const result = await this._mergePresets(data, validPresets, existingPresetNames);
// Merge extraction errors with merge errors // Merge extraction errors with merge errors
result.errors = [...errors, ...result.errors]; result.errors = [...errors, ...result.errors];
result.success = result.errors.length === 0;
return result; return result;
} }
+6 -5
View File
@@ -93,7 +93,8 @@ export class PresetExportDialog extends _AppBase {
return { return {
presetCount, presetCount,
sceneName, sceneName,
filename: this._exportManager.generateExportFilename(sceneName, false), hasScene: !!currentScene,
filename: this._exportManager.generateExportFilename(sceneName),
}; };
} }
@@ -165,14 +166,14 @@ export class PresetExportDialog extends _AppBase {
btn.textContent = ''; btn.textContent = '';
const spinner = document.createElement('i'); const spinner = document.createElement('i');
spinner.className = 'fas fa-spinner fa-spin'; spinner.className = 'fas fa-spinner fa-spin';
const text = document.createTextNode(' Exporting...'); const text = document.createTextNode(' ' + this._adapter.i18n.localize('scrying-pool.presetExport.exporting'));
btn.appendChild(spinner); btn.appendChild(spinner);
btn.appendChild(text); btn.appendChild(text);
// Export presets // Export presets
const jsonString = await this._exportManager.exportAllPresets(); const jsonString = await this._exportManager.exportAllPresets();
const currentScene = this._adapter.scenes.current?.(); const currentScene = this._adapter.scenes.current?.();
const worldName = this._adapter.scenes.current?.()?.parent?.name ?? currentScene?.name ?? 'world'; const worldName = currentScene?.parent?.name ?? currentScene?.name ?? 'world';
const filename = this._exportManager.generateExportFilename(worldName); const filename = this._exportManager.generateExportFilename(worldName);
// Trigger download // Trigger download
@@ -180,7 +181,7 @@ export class PresetExportDialog extends _AppBase {
// Show success notification // Show success notification
if (this._adapter.notifications) { if (this._adapter.notifications) {
this._adapter.notifications.info('Scene presets exported successfully.'); this._adapter.notifications.info(this._adapter.i18n.localize('scrying-pool.presetExport.exportSuccess'));
} }
// Close dialog // Close dialog
@@ -188,7 +189,7 @@ export class PresetExportDialog extends _AppBase {
} catch (err) { } catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err); const errorMsg = err instanceof Error ? err.message : String(err);
if (this._adapter.notifications) { if (this._adapter.notifications) {
this._adapter.notifications.error('Failed to export presets: ' + escapeHtml(errorMsg)); this._adapter.notifications.error(this._adapter.i18n.localize('scrying-pool.presetExport.exportFailed') + ': ' + escapeHtml(errorMsg));
} }
} finally { } finally {
if (btn) { if (btn) {
+26 -11
View File
@@ -8,6 +8,7 @@
*/ */
import { PresetImportExportManager } from '../../core/PresetImportExportManager.js'; import { PresetImportExportManager } from '../../core/PresetImportExportManager.js';
import { isValidScenePreset } from '../../contracts/scene-preset.js';
import { escapeHtml } from '../../utils/html.js'; import { escapeHtml } from '../../utils/html.js';
// Maximum file size: 5MB // Maximum file size: 5MB
@@ -78,6 +79,10 @@ export class PresetImportDialog extends _AppBase {
/** @type {boolean} */ /** @type {boolean} */
this._requiresConfirmation = false; this._requiresConfirmation = false;
// Concurrency guard for async file operations
/** @type {boolean} */
this._parsingInProgress = false;
// Event listener tracking for cleanup // Event listener tracking for cleanup
/** @type {Array<{element: Element, type: string, listener: EventListener}>} */ /** @type {Array<{element: Element, type: string, listener: EventListener}>} */
this._eventListeners = []; this._eventListeners = [];
@@ -110,8 +115,8 @@ export class PresetImportDialog extends _AppBase {
previewItems: this._previewItems, previewItems: this._previewItems,
requiresConfirmation: this._requiresConfirmation, requiresConfirmation: this._requiresConfirmation,
selectedFileName: this._selectedFile?.name ?? null, selectedFileName: this._selectedFile?.name ?? null,
mergeLabel: 'Merge', mergeLabel: this._adapter.i18n.localize('scrying-pool.presetImport.importModeMerge'),
replaceLabel: 'Replace', replaceLabel: this._adapter.i18n.localize('scrying-pool.presetImport.importModeReplace'),
}; };
} }
@@ -272,6 +277,10 @@ export class PresetImportDialog extends _AppBase {
* @private * @private
*/ */
async _parseAndPreviewFile() { async _parseAndPreviewFile() {
if (this._parsingInProgress) return;
this._parsingInProgress = true;
try {
if (!this._selectedFile) { if (!this._selectedFile) {
this._previewItems = []; this._previewItems = [];
this._requiresConfirmation = false; this._requiresConfirmation = false;
@@ -279,7 +288,6 @@ export class PresetImportDialog extends _AppBase {
return; return;
} }
try {
const content = await this._readFileAsText(this._selectedFile); const content = await this._readFileAsText(this._selectedFile);
const data = JSON.parse(content); const data = JSON.parse(content);
@@ -290,20 +298,25 @@ export class PresetImportDialog extends _AppBase {
this._previewItems = []; this._previewItems = [];
const existingNames = new Set(this._scenePresetManager.list().map(p => p.name)); const existingNames = new Set(this._scenePresetManager.list().map(p => p.name));
for (const [name] of Object.entries(data.presets || {})) { for (const [name, presetData] of Object.entries(data.presets || {})) {
let valid = true; let valid = true;
let error = undefined; let error = undefined;
try {
// Check if preset name already exists (for merge mode preview) // Check if preset name already exists (for merge mode preview)
if (this._mode === 'merge' && existingNames.has(name)) { if (this._mode === 'merge' && existingNames.has(name)) {
valid = false; valid = false;
error = 'Already exists - will be skipped'; error = this._adapter.i18n.localize('scrying-pool.presetImport.previewWillSkip');
} }
// Validate preset structure for preview accuracy
if (valid && presetData) {
try {
isValidScenePreset(presetData);
} catch (err) { } catch (err) {
valid = false; valid = false;
error = escapeHtml(err instanceof Error ? err.message : String(err)); error = escapeHtml(err instanceof Error ? err.message : String(err));
} }
}
this._previewItems.push({ name, valid, error }); this._previewItems.push({ name, valid, error });
} }
@@ -318,9 +331,11 @@ export class PresetImportDialog extends _AppBase {
this.render(); this.render();
} catch (err) { } catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err); const errorMsg = err instanceof Error ? err.message : String(err);
this._previewItems = [{ name: this._selectedFile.name, valid: false, error: escapeHtml(errorMsg) }]; this._previewItems = [{ name: this._selectedFile?.name ?? 'unknown', valid: false, error: escapeHtml(errorMsg) }];
this._requiresConfirmation = false; this._requiresConfirmation = false;
this.render(); this.render();
} finally {
this._parsingInProgress = false;
} }
} }
@@ -356,7 +371,7 @@ export class PresetImportDialog extends _AppBase {
async _onImport() { async _onImport() {
if (!this._selectedFile) { if (!this._selectedFile) {
if (this._adapter.notifications) { if (this._adapter.notifications) {
this._adapter.notifications.warn('Please select a file first'); this._adapter.notifications.warn(this._adapter.i18n.localize('scrying-pool.presetImport.selectFileFirst'));
} }
return; return;
} }
@@ -403,7 +418,7 @@ export class PresetImportDialog extends _AppBase {
btn.textContent = ''; btn.textContent = '';
const spinner = document.createElement('i'); const spinner = document.createElement('i');
spinner.className = 'fas fa-spinner fa-spin'; spinner.className = 'fas fa-spinner fa-spin';
const text = document.createTextNode(' Importing...'); const text = document.createTextNode(' ' + this._adapter.i18n.localize('scrying-pool.presetImport.importing'));
btn.appendChild(spinner); btn.appendChild(spinner);
btn.appendChild(text); btn.appendChild(text);
@@ -420,13 +435,13 @@ export class PresetImportDialog extends _AppBase {
// Show errors // Show errors
const errorMessages = result.errors.map(e => escapeHtml(e)).join('\n'); const errorMessages = result.errors.map(e => escapeHtml(e)).join('\n');
if (this._adapter.notifications) { if (this._adapter.notifications) {
this._adapter.notifications.error('Failed to import presets\n' + errorMessages); this._adapter.notifications.error(this._adapter.i18n.localize('scrying-pool.presetImport.importFailed') + '\n' + errorMessages);
} }
} }
} catch (err) { } catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err); const errorMsg = err instanceof Error ? err.message : String(err);
if (this._adapter.notifications) { if (this._adapter.notifications) {
this._adapter.notifications.error('Failed to import presets: ' + escapeHtml(errorMsg)); this._adapter.notifications.error(this._adapter.i18n.localize('scrying-pool.presetImport.importFailed') + ': ' + escapeHtml(errorMsg));
} }
} finally { } finally {
btn.disabled = false; btn.disabled = false;
+1 -1
View File
@@ -21,7 +21,7 @@
</div> </div>
<div class="sp-dialog-buttons"> <div class="sp-dialog-buttons">
<button type="button" class="sp-btn sp-btn-primary sp-export-btn"> <button type="button" class="sp-btn sp-btn-primary sp-export-btn" {{disabled (not hasScene)}}>
<i class="fas fa-download"></i> {{localize "scrying-pool.presetExport.export"}} <i class="fas fa-download"></i> {{localize "scrying-pool.presetExport.export"}}
</button> </button>
<button type="button" class="sp-btn sp-btn-secondary" data-action="close"> <button type="button" class="sp-btn sp-btn-secondary" data-action="close">
@@ -340,9 +340,8 @@ describe('PresetImportExportManager', () => {
const result = await manager.importPresets(JSON.stringify(importData), 'merge'); const result = await manager.importPresets(JSON.stringify(importData), 'merge');
// With the current implementation, invalid presets are reported in errors // Invalid presets produce extraction errors; valid presets still import
// but the operation continues with valid presets expect(result.success).toBe(false); // Extraction errors present
expect(result.success).toBe(true); // Valid preset was imported
expect(result.added).toBe(1); // One valid preset added expect(result.added).toBe(1); // One valid preset added
expect(result.errors.length).toBeGreaterThan(0); expect(result.errors.length).toBeGreaterThan(0);
expect(result.errors.some(e => e.includes('Invalid Preset'))).toBe(true); expect(result.errors.some(e => e.includes('Invalid Preset'))).toBe(true);